use std::collections::BTreeSet;
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::{fmt, fs, thread};
const RUNTIME_DIRECTORY: &str = "XDG_RUNTIME_DIR";
const SOCKET: &str = "beady-eye/changes.sock";
const LONGEST_MESSAGE: usize = 512;
const OWNER_ONLY: u32 = 0o600;
#[derive(Debug, PartialEq, Eq)]
pub enum Answer {
Watched(String),
Unwatched(String),
Malformed,
}
impl fmt::Display for Answer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Answer::Watched(project) => write!(f, "ok {project}"),
Answer::Unwatched(project) => write!(f, "unknown {project}"),
Answer::Malformed => write!(f, "malformed"),
}
}
}
#[derive(Clone, Default)]
pub struct Reported {
projects: Arc<Mutex<Arc<BTreeSet<String>>>>,
}
impl Reported {
pub fn watching<I: IntoIterator<Item = String>>(projects: I) -> Self {
Self {
projects: Arc::new(Mutex::new(Arc::new(projects.into_iter().collect()))),
}
}
pub fn now_watching<I: IntoIterator<Item = String>>(&self, projects: I) {
*self.held() = Arc::new(projects.into_iter().collect());
}
pub fn take(&self, message: &str) -> Answer {
let named = message.trim();
if named.is_empty() || named.len() > LONGEST_MESSAGE {
return Answer::Malformed;
}
let watching = Arc::clone(&self.held());
if watching.contains(named) {
Answer::Watched(named.to_string())
} else {
Answer::Unwatched(named.to_string())
}
}
fn held(&self) -> MutexGuard<'_, Arc<BTreeSet<String>>> {
self.projects.lock().unwrap_or_else(PoisonError::into_inner)
}
}
#[derive(Debug)]
pub enum Refused {
NoRuntimeDirectory,
AlreadyListening(PathBuf),
Unopenable(PathBuf, std::io::Error),
}
impl fmt::Display for Refused {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"nothing can tell bdi a project changed, so every project is polled: "
)?;
match self {
Refused::NoRuntimeDirectory => {
write!(
f,
"this session has no {RUNTIME_DIRECTORY} to put the socket in"
)
}
Refused::AlreadyListening(at) => {
write!(
f,
"another bdi is listening on {}; ss -lxp or lsof -U names which — close it and restart bdi to get the channel",
at.display()
)
}
Refused::Unopenable(at, why) => {
write!(f, "{} could not be opened ({why})", at.display())
}
}
}
}
pub struct Socket {
at: PathBuf,
}
impl Drop for Socket {
fn drop(&mut self) {
let _ = fs::remove_file(&self.at);
}
}
pub fn where_writers_find_bdi() -> Option<PathBuf> {
under(
std::env::var_os(RUNTIME_DIRECTORY)
.map(PathBuf::from)
.as_deref(),
)
}
fn under(runtime_directory: Option<&Path>) -> Option<PathBuf> {
runtime_directory.map(|dir| dir.join(SOCKET))
}
pub fn listen(
at: Option<PathBuf>,
reported: &Reported,
changed: Sender<String>,
) -> Result<Socket, Refused> {
let at = at.ok_or(Refused::NoRuntimeDirectory)?;
let listener = bind(&at)?;
let reported = reported.clone();
thread::spawn(move || accept(&listener, &reported, &changed));
Ok(Socket { at })
}
fn bind(at: &Path) -> Result<UnixListener, Refused> {
if let Some(directory) = at.parent() {
fs::create_dir_all(directory).map_err(|why| Refused::Unopenable(at.to_path_buf(), why))?;
}
let listener = match UnixListener::bind(at) {
Ok(listener) => listener,
Err(taken) if taken.kind() == ErrorKind::AddrInUse => reclaim(at)?,
Err(why) => return Err(Refused::Unopenable(at.to_path_buf(), why)),
};
fs::set_permissions(at, fs::Permissions::from_mode(OWNER_ONLY))
.map_err(|why| Refused::Unopenable(at.to_path_buf(), why))?;
Ok(listener)
}
fn reclaim(at: &Path) -> Result<UnixListener, Refused> {
if UnixStream::connect(at).is_ok() {
return Err(Refused::AlreadyListening(at.to_path_buf()));
}
fs::remove_file(at).map_err(|why| Refused::Unopenable(at.to_path_buf(), why))?;
UnixListener::bind(at).map_err(|why| Refused::Unopenable(at.to_path_buf(), why))
}
fn accept(listener: &UnixListener, reported: &Reported, changed: &Sender<String>) {
for writer in listener.incoming() {
let Ok(writer) = writer else { return };
let (reported, changed) = (reported.clone(), changed.clone());
thread::spawn(move || hear(writer, &reported, &changed));
}
}
fn hear(writer: UnixStream, reported: &Reported, changed: &Sender<String>) {
let Ok(mut answering) = writer.try_clone() else {
return;
};
let mut reading = BufReader::new(writer);
let mut line = Vec::new();
loop {
line.clear();
let room = LONGEST_MESSAGE as u64 + 1;
match (&mut reading).take(room).read_until(b'\n', &mut line) {
Ok(0) | Err(_) => return,
Ok(_) => {}
}
let unended = line.len() > LONGEST_MESSAGE;
let answer = match std::str::from_utf8(&line) {
Ok(message) if !unended => reported.take(message),
_ => Answer::Malformed,
};
if let Answer::Watched(project) = &answer {
if changed.send(project.clone()).is_err() {
return;
}
}
if writeln!(answering, "{answer}").is_err() || unended {
return;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::any::Any;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::Shutdown;
use std::os::unix::net::{UnixListener, UnixStream};
use std::panic::AssertUnwindSafe;
use std::path::Path;
use std::sync::mpsc::{self, Receiver};
use std::time::Duration;
const A_MOMENT: Duration = Duration::from_secs(5);
fn watching<const N: usize>(projects: [&str; N]) -> Reported {
Reported::watching(projects.map(str::to_string))
}
fn a_socket_path(named: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("bdi-{named}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a directory to put the socket in");
dir.join("beady-eye").join("changes.sock")
}
fn open(at: &Path, reported: &Reported) -> (Socket, Receiver<String>) {
let (changed, changes) = mpsc::channel();
let socket = listen(Some(at.to_path_buf()), reported, changed).expect("the socket opens");
(socket, changes)
}
fn connect(at: &Path) -> (UnixStream, BufReader<UnixStream>) {
let writing = UnixStream::connect(at).expect("bdi is listening");
let reading = BufReader::new(writing.try_clone().expect("both ends of the stream"));
(writing, reading)
}
#[track_caller]
fn say(at: &Path, exchanges: &[(&str, &str)]) {
let (mut writing, mut reading) = connect(at);
let mut answered = Vec::new();
for (line, expected) in exchanges {
let mut said = String::new();
let heard = write!(writing, "{line}").and_then(|()| reading.read_line(&mut said));
assert!(
matches!(heard, Ok(1..)),
"bdi let the writer go at {line:?}, after answering {answered:?}"
);
assert_eq!(said.trim_end(), *expected, "bdi's answer to {line:?}");
answered.push(said.trim_end().to_string());
}
}
fn message_of(panic: Box<dyn Any + Send>) -> String {
panic
.downcast_ref::<String>()
.cloned()
.or_else(|| panic.downcast_ref::<&str>().map(|said| said.to_string()))
.expect("the failure carried a message")
}
#[test]
fn a_bdi_that_lets_a_writer_go_is_reported_by_the_message_it_never_answered() {
let at = a_socket_path("lets-the-writer-go");
std::fs::create_dir_all(at.parent().expect("the socket is in a directory"))
.expect("a directory to put the socket in");
let listener = UnixListener::bind(&at).expect("a stand-in for bdi");
let stand_in = thread::spawn(move || {
let (stream, _) = listener.accept().expect("the writer connects");
let mut reading = BufReader::new(stream.try_clone().expect("both ends of the stream"));
let mut line = String::new();
reading.read_line(&mut line).expect("the first message");
writeln!(&stream, "ok atlas").expect("the answer goes back");
line.clear();
reading.read_line(&mut line).expect("the second message");
});
let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| {
say(&at, &[("atlas\n", "ok atlas"), ("ferry\n", "ok ferry")]);
}));
stand_in.join().expect("the stand-in ran to its end");
let red = message_of(outcome.expect_err("say noticed bdi had gone"));
assert!(
red.contains("\"ferry\\n\""),
"the red names the message bdi never answered: {red}"
);
assert!(
red.contains("\"ok atlas\""),
"the red says how far the conversation got: {red}"
);
}
#[test]
fn a_message_naming_a_watched_project_is_taken() {
let reported = watching(["atlas", "ferry"]);
assert_eq!(reported.take("atlas"), Answer::Watched("atlas".to_string()));
}
#[test]
fn a_name_is_taken_without_the_whitespace_around_it() {
let reported = watching(["atlas", "ferry"]);
assert_eq!(
reported.take(" atlas \n"),
Answer::Watched("atlas".to_string())
);
}
#[test]
fn a_message_naming_nothing_bdi_watches_is_said_back_and_nothing_else() {
let reported = watching(["atlas", "ferry"]);
assert_eq!(
reported.take("ghost"),
Answer::Unwatched("ghost".to_string())
);
}
#[test]
fn the_projects_written_are_the_ones_taken_from_then_on() {
let reported = watching(["atlas"]);
reported.now_watching(["ferry".to_string()]);
assert_eq!(reported.take("ferry"), Answer::Watched("ferry".to_string()));
assert_eq!(
reported.take("atlas"),
Answer::Unwatched("atlas".to_string())
);
}
#[test]
fn a_clone_taken_before_the_write_takes_against_what_was_written() {
let reported = watching(["atlas"]);
let held_by_a_connection = reported.clone();
reported.now_watching(["ferry".to_string()]);
assert_eq!(
held_by_a_connection.take("ferry"),
Answer::Watched("ferry".to_string())
);
assert_eq!(
held_by_a_connection.take("atlas"),
Answer::Unwatched("atlas".to_string())
);
}
#[test]
fn a_message_that_names_no_project_is_malformed() {
let reported = watching(["atlas", "ferry"]);
for message in ["", "\n", " ", "\t \r\n"] {
assert_eq!(reported.take(message), Answer::Malformed, "for {message:?}");
}
}
#[test]
fn a_message_too_long_to_be_a_project_name_is_malformed() {
let reported = watching(["atlas", "ferry"]);
let shout = "a".repeat(LONGEST_MESSAGE + 1);
assert_eq!(reported.take(&shout), Answer::Malformed);
}
#[test]
fn without_a_runtime_directory_there_is_no_inbound_channel() {
let (changed, _changes) = mpsc::channel();
let refused = listen(None, &watching(["atlas", "ferry"]), changed);
assert!(matches!(refused, Err(Refused::NoRuntimeDirectory)));
}
#[test]
fn a_writer_naming_a_watched_project_wakes_the_loop() {
let at = a_socket_path("wakes-the-loop");
let reported = watching(["atlas"]);
let (_socket, changes) = open(&at, &reported);
say(&at, &[("atlas\n", "ok atlas")]);
assert_eq!(
changes.recv_timeout(A_MOMENT).ok(),
Some("atlas".to_string()),
"the loop was told which project to collect"
);
}
#[test]
fn a_writer_naming_something_bdi_does_not_watch_is_told_so_and_the_loop_sleeps_on() {
let at = a_socket_path("names-a-stranger");
let (_socket, changes) = open(&at, &watching(["atlas", "ferry"]));
say(&at, &[("ghost\n", "unknown ghost")]);
assert!(
changes.recv_timeout(Duration::from_millis(100)).is_err(),
"nothing bdi watches changed, so there is nothing to collect"
);
}
#[test]
fn a_malformed_message_is_dropped_without_disturbing_the_ones_beside_it() {
let at = a_socket_path("malformed");
let (_socket, changes) = open(&at, &watching(["atlas", "ferry"]));
say(&at, &[("\n", "malformed"), ("atlas\n", "ok atlas")]);
assert!(changes.recv_timeout(A_MOMENT).is_ok());
}
#[test]
fn a_line_that_never_ends_is_malformed_and_the_writer_is_let_go() {
let at = a_socket_path("never-ends");
let reported = watching(["atlas"]);
let (_socket, changes) = open(&at, &reported);
let (mut writing, mut reading) = connect(&at);
let unending = format!("atlas{}", " ".repeat(LONGEST_MESSAGE * 2));
write!(writing, "{unending}").expect("bdi takes the message");
writing
.shutdown(Shutdown::Write)
.expect("the writer has said all it is going to");
let mut said = String::new();
reading.read_line(&mut said).expect("bdi answers");
assert_eq!(
said.trim_end(),
"malformed",
"the prefix bdi read is not the name it spells"
);
let mut afterwards = String::new();
reading
.read_to_string(&mut afterwards)
.expect("bdi is done");
assert_eq!(
afterwards, "",
"bdi let the writer go rather than answering the rest of its line"
);
assert!(
changes.recv_timeout(Duration::from_millis(100)).is_err(),
"nothing bdi watches was named, so there is nothing to collect"
);
}
#[test]
fn the_longest_message_that_still_ends_is_taken() {
let at = a_socket_path("longest-that-ends");
let brink = "a".repeat(LONGEST_MESSAGE - 1);
let reported = watching([brink.as_str(), "atlas"]);
let (_socket, changes) = open(&at, &reported);
say(
&at,
&[
(&format!("{brink}\n"), &format!("ok {brink}")),
("atlas\n", "ok atlas"),
],
);
assert_eq!(
changes.recv_timeout(A_MOMENT).ok(),
Some(brink),
"the loop was told to collect for the name on the boundary"
);
}
#[test]
fn a_writer_may_stay_and_speak_more_than_once() {
let at = a_socket_path("stays-and-speaks");
let (_socket, changes) = open(&at, &watching(["atlas", "ferry"]));
say(&at, &[("atlas\n", "ok atlas"), ("ferry\n", "ok ferry")]);
for said in ["atlas", "ferry"] {
assert_eq!(
changes.recv_timeout(A_MOMENT).ok(),
Some(said.to_string()),
"the channel did not carry {said} on"
);
}
}
#[test]
fn writers_that_know_nothing_of_each_other_are_all_heard() {
let at = a_socket_path("several-writers");
let (_socket, changes) = open(&at, &watching(["atlas", "ferry"]));
say(&at, &[("atlas\n", "ok atlas")]);
say(&at, &[("ferry\n", "ok ferry")]);
for said in ["atlas", "ferry"] {
assert_eq!(
changes.recv_timeout(A_MOMENT).ok(),
Some(said.to_string()),
"the channel did not carry {said} on"
);
}
}
#[test]
fn a_stale_socket_from_a_crashed_run_is_reclaimed() {
let at = a_socket_path("stale-socket");
std::fs::create_dir_all(at.parent().expect("the socket is in a directory"))
.expect("a directory to put the socket in");
drop(UnixListener::bind(&at).expect("a socket the crashed run left"));
assert!(at.exists(), "the crashed run's socket is still there");
let reported = watching(["atlas", "ferry"]);
let (_socket, changes) = open(&at, &reported);
say(&at, &[("atlas\n", "ok atlas")]);
assert!(changes.recv_timeout(A_MOMENT).is_ok());
}
#[test]
fn a_socket_another_bdi_is_listening_on_is_left_alone() {
let at = a_socket_path("two-bdis");
let reported = watching(["atlas", "ferry"]);
let (_first, _changes) = open(&at, &reported);
let (changed, _changes) = mpsc::channel();
let second = listen(Some(at.clone()), &reported, changed);
assert!(matches!(second, Err(Refused::AlreadyListening(_))));
say(&at, &[("atlas\n", "ok atlas")]);
}
#[test]
fn the_line_left_on_the_primary_screen_says_how_to_find_who_is_holding_it() {
let said = Refused::AlreadyListening(PathBuf::from("/run/user/1000/x.sock")).to_string();
assert!(said.contains("another bdi"), "{said}");
assert!(said.contains("/run/user/1000/x.sock"), "{said}");
assert!(said.contains("ss -lxp or lsof -U names which"), "{said}");
}
#[test]
fn the_remedy_for_a_held_socket_says_to_restart_bdi() {
let said = Refused::AlreadyListening(PathBuf::from("/run/user/1000/x.sock")).to_string();
assert!(said.contains("restart bdi"), "{said}");
}
#[test]
fn the_socket_goes_with_the_run_that_made_it() {
let at = a_socket_path("removed-on-exit");
let (socket, _changes) = open(&at, &watching(["atlas", "ferry"]));
assert!(at.exists());
drop(socket);
assert!(!at.exists(), "the next run has nothing to reclaim");
}
#[test]
fn writers_find_bdi_under_the_directory_the_session_owns() {
let socket = under(Some(Path::new("/run/user/1000")));
assert_eq!(
socket,
Some(PathBuf::from("/run/user/1000/beady-eye/changes.sock"))
);
assert_eq!(under(None), None);
}
}