Replace the non-blocking accept + 50ms poll with a plain blocking accept(). A signal-hook thread waits for SIGTERM/SIGINT, sets a flag, and wakes accept() with a throwaway connection to the socket. On shutdown the listener is closed and the socket file unlinked before the grace period, so clients that connect late fail fast with ENOENT instead of queueing and being reset. In-flight handlers are tracked by a counter + Condvar rather than a Vec of JoinHandles. Accepted streams now get a 5s read/write timeout so a silent client cannot pin a handler thread or stretch every shutdown to the full grace period. Transient accept() errors are logged and retried instead of exiting the daemon. A second SIGTERM/SIGINT during the grace period forces an immediate exit with status 1. Tests: wrap the child in the kill-on-drop guard immediately after spawn, drop the redundant wall-clock assertion, and add a case for a client that connects but never sends. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kmrgkpp9YBJ6AMaWYPPZuD
144 lines
4.5 KiB
Rust
144 lines
4.5 KiB
Rust
use common::io::read_string;
|
|
use common::request::Request;
|
|
use std::os::unix::fs::FileTypeExt;
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Idle shutdown takes milliseconds; anything approaching the daemon's 3s
|
|
/// grace period means it is waiting on something it should not be.
|
|
const EXIT_TIMEOUT: Duration = Duration::from_secs(2);
|
|
|
|
const PASSWORD_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../test_passwords");
|
|
|
|
fn temp_socket_path(tag: &str) -> PathBuf {
|
|
let mut path = std::env::temp_dir();
|
|
path.push(format!("saslauthd-{}-{}.sock", tag, std::process::id()));
|
|
let _ = std::fs::remove_file(&path);
|
|
path
|
|
}
|
|
|
|
/// A spawned daemon that is killed if the test panics before stopping it.
|
|
struct Daemon(Child);
|
|
|
|
impl Drop for Daemon {
|
|
fn drop(&mut self) {
|
|
if self.0.try_wait().ok().flatten().is_none() {
|
|
let _ = self.0.kill();
|
|
let _ = self.0.wait();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_socket(path: &PathBuf) -> bool {
|
|
std::fs::metadata(path)
|
|
.map(|m| m.file_type().is_socket())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn spawn_daemon(socket: &PathBuf) -> Daemon {
|
|
let child = Command::new(env!("CARGO_BIN_EXE_saslauthd"))
|
|
.arg("--socket-name")
|
|
.arg(socket)
|
|
.arg("--password-dir")
|
|
.arg(PASSWORD_DIR)
|
|
.spawn()
|
|
.expect("failed to spawn saslauthd");
|
|
// Wrap immediately so a failed startup below still kills the process.
|
|
let daemon = Daemon(child);
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while !is_socket(socket) {
|
|
assert!(Instant::now() < deadline, "daemon never created {socket:?}");
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
daemon
|
|
}
|
|
|
|
fn send_signal(daemon: &Daemon, signal: &str) {
|
|
let child = &daemon.0;
|
|
let status = Command::new("kill")
|
|
.arg(format!("-{signal}"))
|
|
.arg(child.id().to_string())
|
|
.status()
|
|
.expect("failed to run kill");
|
|
assert!(status.success(), "kill -{signal} failed");
|
|
}
|
|
|
|
fn wait_for_exit(daemon: &mut Daemon, timeout: Duration) -> std::process::ExitStatus {
|
|
let child = &mut daemon.0;
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
if let Some(status) = child.try_wait().expect("try_wait failed") {
|
|
return status;
|
|
}
|
|
if Instant::now() >= deadline {
|
|
panic!("daemon did not exit within {timeout:?}");
|
|
}
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
}
|
|
|
|
fn authenticate(socket: &PathBuf, password: &str) -> String {
|
|
let mut stream = UnixStream::connect(socket).expect("connect to daemon");
|
|
Request::new("bp", password, "imap", "")
|
|
.write_to_stream(&mut stream)
|
|
.expect("write request");
|
|
read_string(&mut stream).expect("read response")
|
|
}
|
|
|
|
fn assert_clean_shutdown(signal: &str) {
|
|
let socket = temp_socket_path(signal);
|
|
let mut daemon = spawn_daemon(&socket);
|
|
|
|
// Make sure the daemon actually works before we shut it down.
|
|
assert_eq!(authenticate(&socket, "some-rad-password"), "OK");
|
|
assert_eq!(authenticate(&socket, "wrong-password"), "NO");
|
|
|
|
send_signal(&daemon, signal);
|
|
let status = wait_for_exit(&mut daemon, EXIT_TIMEOUT);
|
|
|
|
assert!(status.success(), "expected exit status 0 on {signal}, got {status}");
|
|
assert!(!socket.exists(), "socket file {socket:?} was left behind");
|
|
}
|
|
|
|
#[test]
|
|
fn it_shuts_down_cleanly_on_sigterm() {
|
|
assert_clean_shutdown("TERM");
|
|
}
|
|
|
|
#[test]
|
|
fn it_shuts_down_cleanly_on_sigint() {
|
|
assert_clean_shutdown("INT");
|
|
}
|
|
|
|
#[test]
|
|
fn it_recreates_a_stale_socket_on_startup() {
|
|
let socket = temp_socket_path("stale");
|
|
std::fs::write(&socket, b"stale").expect("create stale socket file");
|
|
|
|
let mut daemon = spawn_daemon(&socket);
|
|
assert_eq!(authenticate(&socket, "some-rad-password"), "OK");
|
|
|
|
send_signal(&daemon, "TERM");
|
|
let status = wait_for_exit(&mut daemon, EXIT_TIMEOUT);
|
|
assert!(status.success());
|
|
assert!(!socket.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn it_does_not_wait_forever_for_a_silent_client() {
|
|
let socket = temp_socket_path("silent");
|
|
let mut daemon = spawn_daemon(&socket);
|
|
|
|
// Connect but never send a request, leaving a handler blocked on read.
|
|
let _silent = UnixStream::connect(&socket).expect("connect to daemon");
|
|
|
|
send_signal(&daemon, "TERM");
|
|
// Must finish once the grace period (3s) expires, whatever the client does.
|
|
let status = wait_for_exit(&mut daemon, Duration::from_secs(5));
|
|
assert!(status.success());
|
|
assert!(!socket.exists());
|
|
}
|