crates/gunnar-ssh/tests/accept_emfile.rs
//! **The listener outlives `EMFILE`.**
//!
//! MEASURED 2026-08-29 on the rig: once the process had no descriptor left,
//! one failed `accept` ended russh's accept loop and every later ssh client got
//! `Connection reset by peer` until reboot, while gRPC kept answering. Here the
//! process is starved of descriptors on purpose until the listener's `accept`
//! fails, the descriptors are given back, and a real client must still be able
//! to authenticate on the SAME listener.
mod common;
use std::net::TcpStream;
use std::sync::Arc;
use std::time::Duration;
use common::mint_key;
use gunnar_ssh::Access;
use rustix::process::{getrlimit, setrlimit, Resource};
fn open_fds() -> u64 {
std::fs::read_dir("/proc/self/fd")
.map(|d| d.count() as u64)
.unwrap_or(0)
}
async fn authenticates(addr: std::net::SocketAddr, key: &russh::keys::PrivateKey) -> bool {
struct Trusting;
impl russh::client::Handler for Trusting {
type Error = russh::Error;
async fn check_server_key(
&mut self,
_: &russh::keys::PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
Ok(true)
}
}
let config = Arc::new(russh::client::Config::default());
let connect = russh::client::connect(config, addr, Trusting);
let Ok(Ok(mut session)) = tokio::time::timeout(Duration::from_secs(5), connect).await else {
return false;
};
let auth = session.authenticate_publickey(
"git",
russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key.clone()), None),
);
matches!(tokio::time::timeout(Duration::from_secs(5), auth).await, Ok(Ok(a)) if a.success())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_listener_survives_running_out_of_descriptors() {
let key = mint_key();
let fx = common::start_with_key(&key, Access::Read).await;
let addr = fx.server.local_addr();
assert!(
authenticates(addr, &key).await,
"the listener must work before the test starves it"
);
// ── starve the process ──────────────────────────────────────────────────
let original = getrlimit(Resource::Nofile);
let mut starved = original;
starved.current = Some(open_fds() + 6);
setrlimit(Resource::Nofile, starved).expect("lower the soft limit");
// Open raw TCP connections until the process has no descriptor left. Each
// one costs this side a descriptor AND, once accepted, the listener one;
// the ones the listener cannot take sit in the backlog and its `accept`
// fails with EMFILE — the rig's exact state.
let mut held = Vec::new();
let mut client_refused = None;
for _ in 0..64 {
match TcpStream::connect(addr) {
Ok(s) => held.push(s),
Err(e) => {
client_refused = Some(e);
break;
}
}
}
let refused = client_refused.expect("the process never ran out of descriptors");
assert_eq!(
refused.raw_os_error(),
Some(24),
"expected EMFILE, got {refused}"
);
// Give the accept loop time to hit the wall and log its WARN.
tokio::time::sleep(Duration::from_millis(300)).await;
// ── give them back ──────────────────────────────────────────────────────
drop(held);
setrlimit(Resource::Nofile, original).expect("restore the soft limit");
tokio::time::sleep(Duration::from_millis(300)).await;
// ── the SAME listener must still answer ─────────────────────────────────
assert!(
authenticates(addr, &key).await,
"the listener went deaf after EMFILE — the accept loop died with the first failed accept"
);
fx.server.shutdown("test over").await.expect("shutdown");
}