use camino::Utf8PathBuf;
use color_eyre::eyre::{Result, eyre};
use super::{OrphanStop, ProcessId, ServerState, server_state, stop_orphaned_server};
#[cfg(target_os = "linux")]
pub(crate) struct FakePostgres {
child: std::process::Child,
_dir: tempfile::TempDir,
}
#[cfg(target_os = "linux")]
impl FakePostgres {
pub(crate) fn spawn(data_dir: &camino::Utf8Path) -> Result<Self> {
let dir = tempfile::tempdir()?;
let binary = dir.path().join("postgres");
std::fs::copy("/bin/sh", &binary)?;
let child = std::process::Command::new(&binary)
.args(["-c", "read line", "-D", data_dir.as_str()])
.current_dir(data_dir)
.stdin(std::process::Stdio::piped())
.spawn()?;
let server = Self { child, _dir: dir };
server.wait_for_identity(data_dir)?;
Ok(server)
}
fn wait_for_identity(&self, data_dir: &camino::Utf8Path) -> Result<()> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
if super::is_server_for(self.pid(), data_dir) == Some(true) {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
Err(eyre!("the stand-in never read as a postgres server"))
}
pub(crate) fn pid(&self) -> ProcessId { ProcessId::new(self.child.id()) }
}
#[cfg(target_os = "linux")]
impl Drop for FakePostgres {
fn drop(&mut self) {
let _killed = self.child.kill();
let _reaped = self.child.wait();
}
}
struct FixedStop(bool);
impl OrphanStop for FixedStop {
fn stop(&self, _pid: ProcessId) -> bool { self.0 }
}
fn data_dir() -> Result<(tempfile::TempDir, Utf8PathBuf)> {
let temp = tempfile::tempdir()?;
let dir = Utf8PathBuf::from_path_buf(temp.path().to_path_buf())
.map_err(|path| eyre!("non-UTF-8 tempdir {}", path.display()))?;
Ok((temp, dir))
}
#[cfg(unix)]
fn write_pid(dir: &camino::Utf8Path, pid: ProcessId) -> Result<()> {
std::fs::write(dir.join("postmaster.pid"), format!("{pid}\n{dir}\n"))?;
Ok(())
}
#[test]
fn no_pid_file_means_no_server() {
let (_temp, dir) = data_dir().expect("data dir");
assert_eq!(server_state(&dir), ServerState::Absent);
assert!(stop_orphaned_server(&dir, &FixedStop(false)));
}
#[cfg(unix)]
#[test]
fn a_dead_pid_means_no_server() {
let mut child = std::process::Command::new("true")
.spawn()
.expect("spawn true");
let pid = child.id();
child.wait().expect("reap true");
let (_temp, dir) = data_dir().expect("data dir");
write_pid(&dir, ProcessId::new(pid)).expect("pid file");
assert_eq!(server_state(&dir), ServerState::Absent);
}
#[cfg(target_os = "linux")]
#[test]
fn a_live_non_postgres_pid_means_no_server() {
let (_temp, dir) = data_dir().expect("data dir");
write_pid(&dir, ProcessId::new(std::process::id())).expect("pid file");
assert_eq!(server_state(&dir), ServerState::Absent);
assert!(stop_orphaned_server(&dir, &FixedStop(false)));
}
#[cfg(target_os = "linux")]
#[test]
fn a_postgres_serving_another_directory_means_no_server() {
let (_other_temp, other) = data_dir().expect("the other server's directory");
let server = FakePostgres::spawn(&other).expect("a postgres for another directory");
let (_temp, dir) = data_dir().expect("data dir");
write_pid(&dir, server.pid()).expect("pid file");
assert_eq!(server_state(&dir), ServerState::Absent);
assert!(stop_orphaned_server(&dir, &FixedStop(false)));
}
#[cfg(target_os = "linux")]
#[test]
fn a_server_whose_directory_cannot_be_read_is_unconfirmed() {
let (_temp, dir) = data_dir().expect("data dir");
let mut server = FakePostgres::spawn(&dir).expect("a postgres for this directory");
write_pid(&dir, server.pid()).expect("pid file");
server.child.kill().expect("kill without reaping");
let cwd = format!("/proc/{}/cwd", server.pid());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::fs::read_link(&cwd).is_ok() {
assert!(
std::time::Instant::now() < deadline,
"the killed stand-in never became a zombie"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(server_state(&dir), ServerState::Unconfirmed(server.pid()));
assert!(!stop_orphaned_server(&dir, &FixedStop(true)));
}
#[cfg(target_os = "linux")]
#[test]
fn a_live_postgres_must_be_stopped_first() {
let (_temp, dir) = data_dir().expect("data dir");
let server = FakePostgres::spawn(&dir).expect("a postgres for this directory");
write_pid(&dir, server.pid()).expect("pid file");
assert_eq!(server_state(&dir), ServerState::Running(server.pid()));
assert!(!stop_orphaned_server(&dir, &FixedStop(false)));
assert!(stop_orphaned_server(&dir, &FixedStop(true)));
}
#[cfg(target_os = "linux")]
#[test]
fn the_signal_stop_ends_the_process() {
use super::SignalStop;
let mut child = std::process::Command::new("sleep")
.arg("60")
.spawn()
.expect("spawn sleep");
let pid = child.id();
let reaper = std::thread::spawn(move || child.wait());
assert!(
SignalStop.stop(ProcessId::new(pid)),
"the process should exit after SIGQUIT"
);
let _status = reaper.join().expect("join the reaper");
}
#[cfg(unix)]
#[rstest::rstest]
#[case::bare("postgres\n", true)]
#[case::path("/opt/pg/17.4.0/bin/postgres\n", true)]
#[case::other("sleep\n", false)]
#[case::prefix("/usr/bin/postgres-helper\n", false)]
#[case::suffix("/usr/bin/notpostgres\n", false)]
#[case::gone("", false)]
fn a_command_name_is_postgres_by_its_last_component(#[case] comm: &str, #[case] expected: bool) {
assert_eq!(super::names_postgres(comm), expected);
}
#[cfg(all(unix, not(target_os = "linux")))]
#[rstest::rstest]
#[case::plain("p123\nfcwd\nn/d/1-2-0\n", Some("/d/1-2-0"))]
#[case::spaces("p123\nfcwd\nn/d/one two\n", Some("/d/one two"))]
#[case::no_path("p123\nfcwd\n", None)]
#[case::empty_path("p123\nfcwd\nn\n", None)]
#[case::nothing("", None)]
fn lsof_output_yields_the_working_directory(#[case] output: &str, #[case] expected: Option<&str>) {
assert_eq!(
super::parse_lsof_cwd(output),
expected.map(std::path::PathBuf::from)
);
}