use std::fs::{self, File, TryLockError};
use std::hash::{BuildHasher, RandomState};
use std::io::{self, BufRead, BufReader, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
use std::path::Path;
use std::thread;
use std::time::{Duration, Instant};
pub const AFTER_UPDATE_FLAG: &str = "--after-update";
const HANDOFF: Duration = Duration::from_secs(20);
const WAKE: Duration = Duration::from_secs(2);
const RETRY: Duration = Duration::from_millis(100);
pub struct Instance {
_lock: File,
listener: TcpListener,
token: String,
}
pub enum Start {
Run(Option<Instance>),
Exit,
}
pub fn start(background: bool) -> Start {
let Some(dir) = crate::vm::Paths::locate().map(|p| p.dir) else {
return Start::Run(None);
};
let wait = if std::env::args().any(|a| a == AFTER_UPDATE_FLAG) {
HANDOFF
} else {
Duration::ZERO
};
match claim(&dir, wait) {
Ok(Some(instance)) => Start::Run(Some(instance)),
Ok(None) => {
if !background {
wake(&dir);
}
Start::Exit
}
Err(_) => Start::Run(None),
}
}
fn claim(dir: &Path, wait: Duration) -> io::Result<Option<Instance>> {
fs::create_dir_all(dir)?;
let lock = File::options()
.create(true)
.truncate(false)
.write(true)
.open(dir.join("app.lock"))?;
let deadline = Instant::now() + wait;
loop {
match lock.try_lock() {
Ok(()) => break,
Err(TryLockError::WouldBlock) if Instant::now() < deadline => thread::sleep(RETRY),
Err(TryLockError::WouldBlock) => return Ok(None),
Err(TryLockError::Error(e)) => return Err(e),
}
}
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let token = format!(
"{:016x}{:016x}",
RandomState::new().hash_one(std::process::id()),
RandomState::new().hash_one(Instant::now())
);
let port = dir.join("app.port");
let partial = dir.join("app.port.partial");
fs::write(
&partial,
format!("{} {token}", listener.local_addr()?.port()),
)?;
fs::rename(&partial, &port)?;
Ok(Some(Instance {
_lock: lock,
listener,
token,
}))
}
impl Instance {
pub fn serve(&self, show: impl Fn() + Send + 'static) -> io::Result<()> {
let listener = self.listener.try_clone()?;
let expected = format!("show {}", self.token);
thread::Builder::new()
.name("instance".into())
.spawn(move || {
for stream in listener.incoming().flatten() {
let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
let mut line = String::new();
if BufReader::new(stream).read_line(&mut line).is_ok()
&& line.trim_end() == expected
{
show();
}
}
})?;
Ok(())
}
}
fn wake(dir: &Path) -> bool {
let deadline = Instant::now() + WAKE;
loop {
if send_show(dir).is_ok() {
return true;
}
if Instant::now() >= deadline {
return false;
}
thread::sleep(RETRY);
}
}
fn send_show(dir: &Path) -> io::Result<()> {
let text = fs::read_to_string(dir.join("app.port"))?;
let (port, token) = parse_port_file(&text).ok_or(io::ErrorKind::InvalidData)?;
let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(1))?;
stream.write_all(format!("show {token}\n").as_bytes())
}
fn parse_port_file(text: &str) -> Option<(u16, &str)> {
let (port, token) = text.trim().split_once(' ')?;
let port = port.parse().ok()?;
(!token.is_empty()).then_some((port, token))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
fn temp_dir(name: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("ferrodock-instance-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
#[test]
fn reads_the_port_file() {
assert_eq!(parse_port_file("4242 abc\n"), Some((4242, "abc")));
assert_eq!(parse_port_file("4242"), None);
assert_eq!(parse_port_file("4242 "), None);
assert_eq!(parse_port_file("port abc"), None);
assert_eq!(parse_port_file(""), None);
}
#[test]
fn a_second_one_wakes_the_first() {
let dir = temp_dir("wake");
let first = claim(&dir, Duration::ZERO).unwrap().unwrap();
let (shown, show) = mpsc::channel();
first.serve(move || shown.send(()).unwrap()).unwrap();
assert!(claim(&dir, Duration::ZERO).unwrap().is_none());
assert!(wake(&dir));
show.recv_timeout(Duration::from_secs(5)).unwrap();
let port = parse_port_file(&fs::read_to_string(dir.join("app.port")).unwrap())
.unwrap()
.0;
let mut stranger = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
stranger.write_all(b"show nope\n").unwrap();
drop(stranger);
assert!(show.recv_timeout(Duration::from_millis(300)).is_err());
drop(first);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn waits_for_the_old_one_to_quit() {
let dir = temp_dir("handoff");
let old = claim(&dir, Duration::ZERO).unwrap().unwrap();
let quitting = thread::spawn(move || {
thread::sleep(Duration::from_millis(300));
drop(old);
});
let started = Instant::now();
let new = claim(&dir, Duration::from_secs(10)).unwrap();
assert!(new.is_some());
assert!(started.elapsed() >= Duration::from_millis(200));
quitting.join().unwrap();
assert!(claim(&dir, Duration::from_millis(200)).unwrap().is_none());
drop(new);
assert!(claim(&dir, Duration::ZERO).unwrap().is_some());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn nobody_to_wake() {
let dir = temp_dir("nobody");
fs::create_dir_all(&dir).unwrap();
let started = Instant::now();
assert!(!wake(&dir));
assert!(started.elapsed() >= WAKE);
fs::remove_dir_all(dir).unwrap();
}
}