use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use russh::Channel;
use russh::client::{self, Handle};
use russh::keys::{HashAlg, PrivateKeyWithHashAlg};
use tokio::sync::{Mutex, Notify, OwnedSemaphorePermit, Semaphore};
use tokio::time::{sleep, timeout};
use tracing::{debug, error, info, warn};
use super::config::{HostKeyCheckMode, SshConfig};
use super::handler::{
KeyCheckOutcome, SshHandler, default_known_hosts_path, remove_known_hosts_entry,
};
use crate::config::CONNECTION_TIMEOUT_SECS;
use crate::error::{Result, SshMcpError};
use russh::ChannelMsg;
pub const CHANNEL_SEMAPHORE_CAPACITY: usize = 8;
const AUTH_TIMEOUT_SECS: u64 = 20;
const CONNECT_WAIT_TIMEOUT_SECS: u64 = CONNECTION_TIMEOUT_SECS + AUTH_TIMEOUT_SECS;
const MAX_RECONNECT_BACKOFF_MS: u64 = 30_000;
const MIN_HEALTH_PROBE_TTL_MS: u64 = 250;
const MAX_HEALTH_PROBE_TTL_MS: u64 = 5_000;
struct ConnectAttemptGuard<'a> {
is_connecting: &'a AtomicBool,
connect_notify: &'a Notify,
}
impl Drop for ConnectAttemptGuard<'_> {
fn drop(&mut self) {
self.is_connecting.store(false, Ordering::SeqCst);
self.connect_notify.notify_waiters();
}
}
pub struct SshConnectionManager {
pub(crate) config: SshConfig,
session: Arc<Mutex<Option<Handle<SshHandler>>>>,
is_connecting: AtomicBool,
shutting_down: AtomicBool,
connect_notify: Arc<Notify>,
pub(crate) su_channel: Arc<Mutex<Option<Channel<client::Msg>>>>,
pub(crate) is_elevated: AtomicBool,
has_timeout_cmd: AtomicBool,
pub(crate) channel_semaphore: Arc<Semaphore>,
last_health_probe_ok_at: Arc<Mutex<Option<tokio::time::Instant>>>,
health_probe_lock: Arc<Mutex<()>>,
}
impl SshConnectionManager {
pub async fn new(config: SshConfig) -> Self {
Self {
config,
session: Arc::new(Mutex::new(None)),
is_connecting: AtomicBool::new(false),
shutting_down: AtomicBool::new(false),
connect_notify: Arc::new(Notify::new()),
su_channel: Arc::new(Mutex::new(None)),
is_elevated: AtomicBool::new(false),
has_timeout_cmd: AtomicBool::new(false),
channel_semaphore: Arc::new(Semaphore::new(CHANNEL_SEMAPHORE_CAPACITY)),
last_health_probe_ok_at: Arc::new(Mutex::new(None)),
health_probe_lock: Arc::new(Mutex::new(())),
}
}
pub(crate) async fn acquire_command_slot_raw(
&self,
) -> std::result::Result<OwnedSemaphorePermit, tokio::sync::AcquireError> {
self.channel_semaphore.clone().acquire_owned().await
}
pub(crate) async fn acquire_command_slot(&self) -> Result<OwnedSemaphorePermit> {
self.acquire_command_slot_raw()
.await
.map_err(|e| SshMcpError::connection(format!("Failed to acquire command slot: {e}")))
}
pub(crate) fn is_shutting_down(&self) -> bool {
self.shutting_down.load(Ordering::SeqCst)
}
fn ensure_not_shutting_down(&self) -> Result<()> {
if self.is_shutting_down() {
Err(SshMcpError::connection(
"SSH connection manager is shutting down",
))
} else {
Ok(())
}
}
pub async fn connect(&self) -> Result<()> {
self.ensure_not_shutting_down()?;
if self.is_connected().await {
debug!("Already connected to SSH server");
return Ok(());
}
let notified = self.connect_notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if self
.is_connecting
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
debug!("Another connection attempt in progress, waiting...");
let wait_result =
timeout(Duration::from_secs(CONNECT_WAIT_TIMEOUT_SECS), notified).await;
if wait_result.is_err() {
warn!(
"Timed out waiting for in-flight connection attempt after {}s",
CONNECT_WAIT_TIMEOUT_SECS
);
return Err(SshMcpError::connection(format!(
"Timed out waiting for in-flight connection attempt after {}s",
CONNECT_WAIT_TIMEOUT_SECS
)));
}
return if self.is_shutting_down() {
self.ensure_not_shutting_down()
} else if self.is_connected().await {
Ok(())
} else {
Err(SshMcpError::connection("Connection failed by another task"))
};
}
let _attempt_guard = ConnectAttemptGuard {
is_connecting: &self.is_connecting,
connect_notify: self.connect_notify.as_ref(),
};
self.do_connect().await
}
async fn do_connect(&self) -> Result<()> {
info!(
"Connecting to SSH server {}:{}...",
self.config.host, self.config.port
);
let connection_timeout = Duration::from_secs(CONNECTION_TIMEOUT_SECS);
let ssh_config = Arc::new(client::Config {
keepalive_interval: Some(Duration::from_secs(self.config.keepalive_interval)),
keepalive_max: self.config.keepalive_max as usize,
..Default::default()
});
let addr = format!("{}:{}", self.config.host, self.config.port);
let key_outcome = Arc::new(std::sync::Mutex::new(None::<KeyCheckOutcome>));
let handler = SshHandler::new(
self.config.host.clone(),
self.config.port,
self.config.host_key_checking,
self.config.known_hosts.clone(),
)
.with_key_check_outcome(key_outcome.clone());
match self
.attempt_connect(&ssh_config, &addr, handler, connection_timeout)
.await
{
Ok(session) => self.finish_connect(session).await,
Err(first_err) => {
let outcome = key_outcome.lock().unwrap().take();
if matches!(outcome, Some(KeyCheckOutcome::KeyChanged))
&& self.config.host_key_checking == HostKeyCheckMode::AcceptNew
{
let Some(path) = self.resolve_known_hosts_path() else {
error!(
host = %self.config.host,
port = self.config.port,
"Host key changed but cannot resolve known_hosts path for recovery"
);
return Err(first_err);
};
warn!(
host = %self.config.host,
port = self.config.port,
path = %path.display(),
"Host key changed in accept-new mode; \
removing stale known_hosts entry and retrying once"
);
remove_known_hosts_entry(&self.config.host, self.config.port, &path).map_err(
|e| {
SshMcpError::connection(format!(
"Failed to remove stale known_hosts entry: {e}"
))
},
)?;
let retry_handler = SshHandler::new(
self.config.host.clone(),
self.config.port,
self.config.host_key_checking,
self.config.known_hosts.clone(),
);
match self
.attempt_connect(&ssh_config, &addr, retry_handler, connection_timeout)
.await
{
Ok(session) => {
info!(
host = %self.config.host,
port = self.config.port,
"SSH reconnection succeeded after host key rotation"
);
self.finish_connect(session).await
}
Err(retry_err) => {
error!(
error = ?retry_err,
"SSH connection failed on retry after key rotation"
);
Err(retry_err)
}
}
} else {
error!(error = ?first_err, "SSH connection failed");
Err(first_err)
}
}
}
}
async fn attempt_connect(
&self,
ssh_config: &Arc<client::Config>,
addr: &str,
handler: SshHandler,
connection_timeout: Duration,
) -> Result<Handle<SshHandler>> {
timeout(
connection_timeout,
client::connect(ssh_config.clone(), addr, handler),
)
.await
.map_err(|_| {
error!("SSH connection timeout after {}s", CONNECTION_TIMEOUT_SECS);
SshMcpError::connection(format!(
"Connection timeout after {}s",
CONNECTION_TIMEOUT_SECS
))
})?
.map_err(|e| SshMcpError::connection(e.to_string()))
}
async fn finish_connect(&self, mut session: Handle<SshHandler>) -> Result<()> {
self.authenticate(&mut session).await?;
let mut session = Some(session);
{
let mut session_guard = self.session.lock().await;
if !self.is_shutting_down() {
*session_guard = session.take();
}
}
if let Some(session) = session {
let _ = session
.disconnect(russh::Disconnect::ByApplication, "", "")
.await;
return self.ensure_not_shutting_down();
}
{
let mut probe_guard = self.last_health_probe_ok_at.lock().await;
*probe_guard = None;
}
info!(
"Successfully connected to {}@{}:{}",
self.config.username, self.config.host, self.config.port
);
if self.config.su_password.is_some() {
debug!("su_password configured, attempting elevation...");
if let Err(e) = self.ensure_elevated().await {
warn!(error = ?e, "Failed to elevate to root. Commands will run as normal user.");
}
}
Ok(())
}
fn resolve_known_hosts_path(&self) -> Option<PathBuf> {
self.config
.known_hosts
.clone()
.or_else(default_known_hosts_path)
}
async fn authenticate(&self, session: &mut Handle<SshHandler>) -> Result<()> {
if let Some(ref password) = self.config.password {
debug!(
"Attempting password authentication for user '{}'",
self.config.username
);
let auth_result = timeout(
Duration::from_secs(AUTH_TIMEOUT_SECS),
session.authenticate_password(&self.config.username, password),
)
.await
.map_err(|_| {
SshMcpError::auth(format!(
"Authentication timed out after {}s",
AUTH_TIMEOUT_SECS
))
})?
.map_err(|e| SshMcpError::auth(e.to_string()))?;
if auth_result.success() {
info!("Password authentication successful");
return Ok(());
} else {
return Err(SshMcpError::auth("Password authentication rejected"));
}
}
if let Some(ref key_content) = self.config.private_key {
debug!(
"Attempting key authentication for user '{}'",
self.config.username
);
let key = Arc::new(
russh::keys::PrivateKey::from_openssh(key_content.as_bytes()).map_err(|e| {
SshMcpError::SshKey(format!("Failed to parse private key: {}", e))
})?,
);
let hash_attempts: &[Option<HashAlg>] = if key.algorithm().is_rsa() {
&[Some(HashAlg::Sha256), Some(HashAlg::Sha512), None]
} else {
&[None]
};
for hash_alg in hash_attempts {
debug!(
alg = %key.algorithm(),
?hash_alg,
"Attempting publickey authentication"
);
let key_with_alg = PrivateKeyWithHashAlg::new(Arc::clone(&key), *hash_alg);
let auth_result = timeout(
Duration::from_secs(AUTH_TIMEOUT_SECS),
session.authenticate_publickey(&self.config.username, key_with_alg),
)
.await
.map_err(|_| {
SshMcpError::auth(format!(
"Authentication timed out after {}s",
AUTH_TIMEOUT_SECS
))
})?
.map_err(|e| SshMcpError::auth(e.to_string()))?;
if auth_result.success() {
info!("Key authentication successful");
return Ok(());
}
}
return Err(SshMcpError::auth("Key authentication rejected"));
}
Err(SshMcpError::auth(
"No authentication method available (require password or private_key)",
))
}
pub async fn is_connected(&self) -> bool {
let session_guard = self.session.lock().await;
session_guard.is_some()
}
pub async fn ensure_connected(&self) -> Result<()> {
self.ensure_not_shutting_down()?;
if !self.is_connected().await {
return self
.connect_with_retry("no active session found during ensure_connected")
.await;
}
if self.is_health_probe_fresh().await {
return Ok(());
}
let _probe_guard = self.health_probe_lock.lock().await;
if self.is_health_probe_fresh().await {
return Ok(());
}
if let Err(probe_error) = self.run_health_probe().await {
warn!(
error = ?probe_error,
"SSH health probe failed, invalidating session before reconnect"
);
self.invalidate_session("health probe failed").await;
self.connect_with_retry("health probe failed during ensure_connected")
.await?;
} else {
self.mark_health_probe_ok().await;
}
Ok(())
}
fn health_probe_ttl(&self) -> Duration {
let ttl_ms = self
.config
.health_probe_timeout_ms
.saturating_mul(2)
.clamp(MIN_HEALTH_PROBE_TTL_MS, MAX_HEALTH_PROBE_TTL_MS);
Duration::from_millis(ttl_ms)
}
async fn is_health_probe_fresh(&self) -> bool {
let guard = self.last_health_probe_ok_at.lock().await;
if let Some(last_ok_at) = guard.as_ref() {
return last_ok_at.elapsed() < self.health_probe_ttl();
}
false
}
async fn mark_health_probe_ok(&self) {
let mut guard = self.last_health_probe_ok_at.lock().await;
*guard = Some(tokio::time::Instant::now());
}
async fn run_health_probe(&self) -> Result<()> {
let ping_result = {
let session_guard = self.session.lock().await;
let session = session_guard
.as_ref()
.ok_or_else(|| SshMcpError::connection("SSH connection not established"))?;
timeout(
Duration::from_millis(self.config.health_probe_timeout_ms),
session.send_ping(),
)
.await
};
match ping_result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(SshMcpError::connection(format!(
"SSH health probe ping failed: {e}"
))),
Err(_) => Err(SshMcpError::connection(format!(
"SSH health probe timed out after {}ms",
self.config.health_probe_timeout_ms
))),
}
}
async fn connect_with_retry(&self, reason: &str) -> Result<()> {
let max_attempts = self.config.reconnect_retries.saturating_add(1);
let mut attempt: u64 = 1;
let mut last_error: Option<SshMcpError> = None;
while attempt <= max_attempts {
match self.connect().await {
Ok(()) => {
if attempt > 1 {
info!(
attempts = attempt,
reason = reason,
"SSH reconnect succeeded"
);
}
return Ok(());
}
Err(err) => {
let backoff_ms = self.backoff_for_attempt(attempt);
warn!(
attempt = attempt,
max_attempts = max_attempts,
backoff_ms = backoff_ms,
reason = reason,
error = ?err,
"SSH reconnect attempt failed"
);
last_error = Some(err);
if attempt < max_attempts && backoff_ms > 0 {
sleep(Duration::from_millis(backoff_ms)).await;
}
}
}
attempt = attempt.saturating_add(1);
}
if let Some(err) = last_error {
return Err(err);
}
Err(SshMcpError::connection(
"Reconnect retry loop ended without connection result",
))
}
fn backoff_for_attempt(&self, attempt: u64) -> u64 {
let exponent = attempt.saturating_sub(1).min(63) as u32;
let factor = 1_u64 << exponent;
self.config
.reconnect_backoff_ms
.saturating_mul(factor)
.min(MAX_RECONNECT_BACKOFF_MS)
}
pub async fn with_session<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&Handle<SshHandler>) -> T,
{
self.ensure_not_shutting_down()?;
let session_guard = self.session.lock().await;
match session_guard.as_ref() {
Some(session) => Ok(f(session)),
None => Err(SshMcpError::connection("SSH connection not established")),
}
}
pub async fn open_channel(&self) -> Result<Channel<client::Msg>> {
self.ensure_not_shutting_down()?;
let session_guard = self.session.lock().await;
let session = session_guard
.as_ref()
.ok_or_else(|| SshMcpError::connection("SSH connection not established"))?;
let channel = session
.channel_open_session()
.await
.map_err(|e| SshMcpError::connection(format!("Failed to open channel: {}", e)))?;
Ok(channel)
}
pub fn is_elevated(&self) -> bool {
self.is_elevated.load(Ordering::SeqCst)
}
pub fn use_timeout_wrapper(&self) -> bool {
self.has_timeout_cmd.load(Ordering::SeqCst)
}
pub fn disable_timeout_wrapper(&self) {
self.has_timeout_cmd.store(false, Ordering::SeqCst);
warn!("timeout wrapper disabled due to errors, falling back to pkill");
}
pub(crate) async fn determine_timeout_wrapper_usage(&self) -> bool {
if self.use_timeout_wrapper() {
return true;
}
let _ = self.check_timeout_availability().await;
self.use_timeout_wrapper()
}
pub async fn check_timeout_availability(&self) -> bool {
if self.has_timeout_cmd.load(Ordering::SeqCst) {
return true;
}
let mut channel = match self.open_channel().await {
Ok(ch) => ch,
Err(e) => {
debug!(error = ?e, "Failed to open channel for timeout detection");
return false;
}
};
let exec_result = channel
.exec(true, "sh -c 'command -v timeout'")
.await
.map_err(|e| {
SshMcpError::connection(format!("Failed to exec detection command: {}", e))
});
if exec_result.is_err() {
debug!("Failed to exec timeout detection command");
return false;
}
let mut output = String::new();
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { data } => {
output.push_str(&String::from_utf8_lossy(&data));
}
ChannelMsg::Close | ChannelMsg::Eof => {
break;
}
_ => {
}
}
}
let available = !output.is_empty();
self.has_timeout_cmd.store(available, Ordering::SeqCst);
if available {
info!("timeout command available on remote host");
} else {
info!("timeout command NOT available, using fallback pkill");
}
available
}
pub async fn has_su_channel(&self) -> bool {
let channel_guard = self.su_channel.lock().await;
channel_guard.is_some()
}
pub async fn with_su_channel<F, Fut, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&mut Option<Channel<client::Msg>>) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
self.ensure_not_shutting_down()?;
let mut channel_guard = self.su_channel.lock().await;
f(&mut channel_guard).await
}
pub async fn ensure_elevated(&self) -> Result<()> {
self.ensure_not_shutting_down()?;
if self.is_elevated.load(Ordering::SeqCst) {
let channel_guard = self.su_channel.lock().await;
if channel_guard.is_some() {
return Ok(());
}
}
let su_password = self
.config
.su_password
.clone()
.ok_or_else(|| SshMcpError::elevation_failed("No su_password configured"))?;
let channel = self
.open_channel()
.await
.map_err(|e| SshMcpError::elevation_failed(format!("Failed to open channel: {}", e)))?;
debug!("Opened channel for su elevation");
channel
.request_pty(
true, "xterm",
80, 24, 0, 0, &[], )
.await
.map_err(|e| SshMcpError::elevation_failed(format!("Failed to request PTY: {}", e)))?;
debug!("PTY requested");
channel.request_shell(true).await.map_err(|e| {
SshMcpError::elevation_failed(format!("Failed to request shell: {}", e))
})?;
debug!("Shell requested, starting su elevation...");
channel.data(b"su -\n".as_slice()).await.map_err(|e| {
SshMcpError::elevation_failed(format!("Failed to send su command: {}", e))
})?;
let elevation_result = self.handle_su_elevation(channel, &su_password).await;
match elevation_result {
Ok(elevated_channel) => {
let mut channel_guard = self.su_channel.lock().await;
if self.is_shutting_down() {
drop(channel_guard);
let _ = elevated_channel.eof().await;
return self.ensure_not_shutting_down();
}
*channel_guard = Some(elevated_channel);
self.is_elevated.store(true, Ordering::SeqCst);
info!("Successfully elevated to root via su");
Ok(())
}
Err(e) => {
self.is_elevated.store(false, Ordering::SeqCst);
Err(e)
}
}
}
async fn handle_su_elevation(
&self,
mut channel: Channel<client::Msg>,
password: &str,
) -> Result<Channel<client::Msg>> {
use russh::ChannelMsg;
let elevation_timeout = Duration::from_secs(10);
let mut buffer = String::new();
let mut password_sent = false;
let deadline = tokio::time::Instant::now() + elevation_timeout;
loop {
if tokio::time::Instant::now() > deadline {
return Err(SshMcpError::elevation_failed("su elevation timed out"));
}
let wait_result =
tokio::time::timeout(Duration::from_millis(500), channel.wait()).await;
match wait_result {
Ok(Some(msg)) => {
match msg {
ChannelMsg::Data { data } => {
let text = String::from_utf8_lossy(&data);
buffer.push_str(&text);
debug!(su_buffer_len = buffer.len(), "su buffer received");
if !password_sent && buffer.to_lowercase().contains("password") {
debug!("Password prompt detected, sending password...");
channel
.data(format!("{}\n", password).as_bytes())
.await
.map_err(|e| {
SshMcpError::elevation_failed(format!(
"Failed to send password: {}",
e
))
})?;
password_sent = true;
buffer.clear();
}
if password_sent && buffer.contains('#') {
debug!("Root prompt detected, elevation successful");
return Ok(channel);
}
if buffer.to_lowercase().contains("authentication failure")
|| buffer.to_lowercase().contains("incorrect password")
|| buffer.to_lowercase().contains("su: failed")
|| buffer.to_lowercase().contains("su: authentication")
{
return Err(SshMcpError::elevation_failed(format!(
"su authentication failed: {}",
buffer
)));
}
}
ChannelMsg::Close => {
return Err(SshMcpError::elevation_failed(
"Channel closed before elevation completed",
));
}
_ => {
}
}
}
Ok(None) => {
return Err(SshMcpError::elevation_failed(
"Channel ended before elevation completed",
));
}
Err(_) => {
continue;
}
}
}
}
pub fn get_su_password(&self) -> Option<&str> {
self.config.su_password.as_deref()
}
pub fn get_sudo_password(&self) -> Option<&str> {
self.config.sudo_password.as_deref()
}
pub async fn set_su_password(&self, password: Option<String>) -> Result<()> {
if password.is_some() {
self.ensure_elevated().await?;
} else {
let mut channel_guard = self.su_channel.lock().await;
if let Some(ch) = channel_guard.take() {
let _ = ch.eof().await;
}
self.is_elevated.store(false, Ordering::SeqCst);
}
Ok(())
}
pub async fn close(&self) {
self.shutting_down.store(true, Ordering::SeqCst);
let su_channel = {
let mut channel_guard = self.su_channel.lock().await;
channel_guard.take()
};
if let Some(ch) = su_channel {
let _ = ch.eof().await;
}
self.is_elevated.store(false, Ordering::SeqCst);
let session = {
let mut session_guard = self.session.lock().await;
session_guard.take()
};
if let Some(session) = session {
let _ = session
.disconnect(russh::Disconnect::ByApplication, "", "")
.await;
}
{
let mut probe_guard = self.last_health_probe_ok_at.lock().await;
*probe_guard = None;
}
info!("SSH connection closed");
}
pub async fn invalidate_session(&self, reason: &str) {
warn!(reason = ?reason, "Invalidating SSH session");
let channel = {
let mut channel_guard = self.su_channel.lock().await;
channel_guard.take()
};
if let Some(ch) = channel {
let _ = ch.eof().await;
}
self.is_elevated.store(false, Ordering::SeqCst);
let session = {
let mut session_guard = self.session.lock().await;
session_guard.take()
};
if let Some(session) = session {
let _ = tokio::time::timeout(
Duration::from_millis(500),
session.disconnect(russh::Disconnect::ByApplication, "", ""),
)
.await;
}
{
let mut probe_guard = self.last_health_probe_ok_at.lock().await;
*probe_guard = None;
}
debug!(reason = ?reason, "Session invalidated");
}
pub async fn reconnect(&self) -> Result<()> {
self.ensure_not_shutting_down()?;
self.invalidate_session("explicit reconnect requested")
.await;
self.connect_with_retry("explicit reconnect requested")
.await
}
}
impl std::fmt::Debug for SshConnectionManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SshConnectionManager")
.field("host", &self.config.host)
.field("port", &self.config.port)
.field("username", &self.config.username)
.field("is_connecting", &self.is_connecting.load(Ordering::SeqCst))
.field("shutting_down", &self.shutting_down.load(Ordering::SeqCst))
.field("is_elevated", &self.is_elevated.load(Ordering::SeqCst))
.field(
"has_timeout_cmd",
&self.has_timeout_cmd.load(Ordering::SeqCst),
)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_connection_manager_creation() {
let config = SshConfig::new("localhost", "testuser")
.with_port(22)
.with_password("testpass");
let manager = SshConnectionManager::new(config).await;
assert!(!manager.is_connected().await);
assert!(!manager.is_elevated());
}
#[tokio::test]
async fn test_not_connected_initially() {
let config = SshConfig::new("localhost", "testuser");
let manager = SshConnectionManager::new(config).await;
let result = manager.open_channel().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_close_prevents_new_ssh_work() {
let config = SshConfig::new("127.0.0.1", "testuser").with_port(9);
let manager = SshConnectionManager::new(config).await;
manager.close().await;
manager.close().await;
assert!(manager.is_shutting_down());
assert!(manager.connect().await.is_err());
assert!(manager.ensure_connected().await.is_err());
assert!(manager.open_channel().await.is_err());
assert!(manager.reconnect().await.is_err());
}
#[tokio::test]
async fn test_cancelled_connect_releases_owner_flag() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind test listener");
let port = listener.local_addr().expect("test listener address").port();
let accept_task = tokio::spawn(async move {
let (_stream, _) = listener.accept().await.expect("accept test connection");
std::future::pending::<()>().await;
});
let config = SshConfig::new("127.0.0.1", "testuser")
.with_port(port)
.with_password("testpass");
let manager = SshConnectionManager::new(config).await;
let result = timeout(Duration::from_millis(100), manager.connect()).await;
assert!(result.is_err(), "silent peer should keep connect in flight");
assert!(
!manager.is_connecting.load(Ordering::SeqCst),
"cancelling connect must release the owner flag"
);
accept_task.abort();
}
}