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(120);
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 after_update = std::env::args().any(|a| a == AFTER_UPDATE_FLAG);
let wait = if after_update {
HANDOFF
} else {
Duration::ZERO
};
match claim(&dir, wait) {
Ok(Some(instance)) => Start::Run(Some(instance)),
Ok(None) if after_update => Start::Run(None),
Ok(None) => {
if !background {
wake(&dir);
}
Start::Exit
}
Err(_) => Start::Run(None),
}
}
fn claim(dir: &Path, wait: Duration) -> io::Result<Option<Instance>> {
crate::vm::private_dir(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");
let _ = fs::remove_file(&partial);
let mut options = File::options();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
options
.open(&partial)?
.write_all(format!("{} {token}", listener.local_addr()?.port()).as_bytes())?;
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()
&& same(line.trim_end().as_bytes(), expected.as_bytes())
{
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 same(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0, |diff, (x, y)| diff | (x ^ y)) == 0
}
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::*;
#[test]
fn compares_tokens_whole() {
assert!(same(b"show abc", b"show abc"));
assert!(!same(b"show abc", b"show abd"));
assert!(!same(b"show ab", b"show abc"));
assert!(same(b"", b""));
}
#[test]
#[cfg(unix)]
fn keeps_the_token_private() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("private");
let instance = claim(&dir, Duration::ZERO).unwrap().unwrap();
let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&dir), 0o700);
assert_eq!(mode(&dir.join("app.port")), 0o600);
drop(instance);
let again = claim(&dir, Duration::ZERO).unwrap().unwrap();
assert_eq!(mode(&dir.join("app.port")), 0o600);
drop(again);
fs::remove_dir_all(dir).unwrap();
}
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();
}
}