use autofork_core::config::Paths;
use autofork_core::protocol::{encode, Event, Request, RequestBody, Response, ResponseBody};
use autofork_core::sys;
use autofork_core::PROTO_VERSION;
use std::path::Path;
use std::time::{Duration, Instant};
pub struct Client {
conn: Conn,
next_id: u64,
}
#[derive(Debug)]
pub enum ClientError {
NotRunning,
Io(std::io::Error),
Protocol(String),
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientError::NotRunning => write!(f, "daemon not running"),
ClientError::Io(e) => write!(f, "io: {e}"),
ClientError::Protocol(m) => write!(f, "protocol: {m}"),
}
}
}
impl From<std::io::Error> for ClientError {
fn from(e: std::io::Error) -> Self {
ClientError::Io(e)
}
}
impl Client {
pub fn connect(paths: &Paths, timeout: Duration) -> Result<Self, ClientError> {
let conn = Conn::connect(paths, timeout).map_err(|_| ClientError::NotRunning)?;
Ok(Self { conn, next_id: 1 })
}
pub fn connect_or_spawn(paths: &Paths, budget: Duration) -> Result<Self, ClientError> {
let deadline = Instant::now() + budget;
if let Ok(c) = Self::connect(paths, budget) {
return Ok(c);
}
spawn_daemon_locked(paths, deadline)?;
loop {
match Self::connect(paths, Duration::from_secs(30)) {
Ok(c) => return Ok(c),
Err(_) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => return Err(e),
}
}
}
pub fn stop_wait(&mut self, ev: Event) -> Result<ResponseBody, ClientError> {
self.conn.set_read_timeout(Duration::from_secs(4 * 3600))?;
self.request(RequestBody::StopWait(ev))
}
pub fn request(&mut self, body: RequestBody) -> Result<ResponseBody, ClientError> {
let id = self.next_id;
self.next_id += 1;
let req = Request {
proto: PROTO_VERSION,
id,
body,
};
let line = encode(&req).map_err(|e| ClientError::Protocol(e.to_string()))?;
self.conn.write_all(line.as_bytes())?;
let resp_line = self.conn.read_line()?;
if resp_line.is_empty() {
return Err(ClientError::Protocol("connection closed".into()));
}
let resp: Response = serde_json::from_str(resp_line.trim())
.map_err(|e| ClientError::Protocol(e.to_string()))?;
Ok(resp.body)
}
pub fn ensure_current_version(mut self, paths: &Paths) -> Result<Client, ClientError> {
let mine = env!("CARGO_PKG_VERSION");
let outdated = match self.request(RequestBody::Hello {
version: mine.to_string(),
}) {
Ok(ResponseBody::HelloInfo { version }) => semver_lt(&version, mine),
Ok(ResponseBody::Error { .. }) | Err(_) => true,
Ok(_) => false,
};
if !outdated {
return Ok(self);
}
tracing::info!("retiring outdated daemon");
let _ = self.request(RequestBody::Shutdown { drain: true });
drop(self);
let deadline = Instant::now() + Duration::from_secs(120);
while Instant::now() < deadline {
if try_flock(&paths.daemon_lock()).is_some() {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
Client::connect_or_spawn(paths, Duration::from_secs(10))
}
}
fn semver_lt(a: &str, b: &str) -> bool {
let parse = |s: &str| -> [u64; 3] {
let mut out = [0u64; 3];
for (i, part) in s.trim().split('.').take(3).enumerate() {
out[i] = part
.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse()
.unwrap_or(0);
}
out
};
parse(a) < parse(b)
}
pub(crate) fn try_flock(path: &Path) -> Option<std::fs::File> {
sys::try_lock_file(path)
}
fn flock_until(path: &Path, deadline: Instant) -> Option<std::fs::File> {
loop {
if let Some(f) = try_flock(path) {
return Some(f);
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(25));
}
}
fn daemon_binary() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let candidate = exe
.parent()?
.join(format!("autofork-daemon{}", sys::EXE_SUFFIX));
candidate.is_file().then_some(candidate)
}
pub fn spawn_daemon_detached(paths: &Paths) {
let deadline = Instant::now() + Duration::from_millis(500);
let _ = spawn_daemon_locked(paths, deadline);
}
fn spawn_daemon_locked(paths: &Paths, deadline: Instant) -> Result<(), ClientError> {
let Some(_spawn_lock) = flock_until(&paths.spawn_lock(), deadline) else {
return Ok(());
};
if Conn::probe(paths) {
return Ok(());
}
{
let Some(_daemon_lock) = try_flock(&paths.daemon_lock()) else {
return Ok(());
};
#[cfg(unix)]
let _ = std::fs::remove_file(paths.socket());
}
let Some(bin) = daemon_binary() else {
return Err(ClientError::Protocol(
"autofork-daemon binary not found next to the CLI".into(),
));
};
let log_path = paths.daemon_log();
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)?;
let log2 = log.try_clone()?;
let mut cmd = std::process::Command::new(bin);
sys::spawn_detached(&mut cmd, log, log2)?;
Ok(())
}
pub(crate) fn parent_exe() -> Option<std::path::PathBuf> {
sys::exe_path(sys::parent_pid())
}
#[cfg(unix)]
use unix::Conn;
#[cfg(windows)]
use windows::Conn;
#[cfg(unix)]
mod unix {
use super::Paths;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::time::Duration;
pub struct Conn {
stream: UnixStream,
reader: BufReader<UnixStream>,
}
impl Conn {
pub fn connect(paths: &Paths, timeout: Duration) -> std::io::Result<Self> {
let stream = UnixStream::connect(paths.socket())?;
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
let reader = BufReader::new(stream.try_clone()?);
Ok(Self { stream, reader })
}
pub fn probe(paths: &Paths) -> bool {
UnixStream::connect(paths.socket()).is_ok()
}
pub fn set_read_timeout(&mut self, d: Duration) -> std::io::Result<()> {
self.stream.set_read_timeout(Some(d))
}
pub fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
self.stream.write_all(bytes)
}
pub fn read_line(&mut self) -> std::io::Result<String> {
let mut line = String::new();
self.reader.read_line(&mut line)?;
Ok(line)
}
}
}
#[cfg(windows)]
mod windows {
use super::Paths;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient};
const PIPE_BUSY: i32 = 231;
pub struct Conn {
rt: tokio::runtime::Runtime,
io: BufReader<NamedPipeClient>,
timeout: Duration,
}
impl Conn {
pub fn connect(paths: &Paths, timeout: Duration) -> std::io::Result<Self> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let name = paths.pipe_name();
let deadline = Instant::now() + timeout;
let pipe = rt.block_on(async {
loop {
match ClientOptions::new().open(&name) {
Ok(c) => break Ok(c),
Err(e)
if e.raw_os_error() == Some(PIPE_BUSY) && Instant::now() < deadline =>
{
tokio::time::sleep(Duration::from_millis(20)).await;
}
Err(e) => break Err(e),
}
}
})?;
Ok(Self {
rt,
io: BufReader::new(pipe),
timeout,
})
}
pub fn probe(paths: &Paths) -> bool {
Self::connect(paths, Duration::from_millis(500)).is_ok()
}
pub fn set_read_timeout(&mut self, d: Duration) -> std::io::Result<()> {
self.timeout = d;
Ok(())
}
pub fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
let Self { rt, io, timeout } = self;
rt.block_on(async {
tokio::time::timeout(*timeout, io.write_all(bytes))
.await
.map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "write"))?
})
}
pub fn read_line(&mut self) -> std::io::Result<String> {
let Self { rt, io, timeout } = self;
let mut line = String::new();
rt.block_on(async {
tokio::time::timeout(*timeout, io.read_line(&mut line))
.await
.map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "read"))?
})?;
Ok(line)
}
}
}
#[cfg(test)]
mod tests {
use super::semver_lt;
#[test]
fn semver_ordering() {
assert!(semver_lt("0.1.0", "0.2.0"));
assert!(semver_lt("0.1.9", "0.1.10"));
assert!(!semver_lt("0.2.0", "0.1.9"));
assert!(!semver_lt("1.0.0", "1.0.0"));
assert!(semver_lt("garbage", "0.0.1"));
}
}