use std::{
io::{IsTerminal, Write, stdin, stdout},
net::SocketAddr,
path::Path,
thread::sleep,
time::{Duration, Instant},
};
use anyhow::{Result, bail};
use crate::{
client::call,
control::{Info, Role},
mcp::PORT_HUNT_RANGE,
};
const RETURN_TIMEOUT: Duration = Duration::from_secs(10);
const POLL: Duration = Duration::from_millis(100);
pub struct Running {
pub addr: SocketAddr,
pub info: Info,
}
impl Running {
fn describe(&self) -> String {
let consoles = match self.info.consoles {
0 => "no consoles".to_string(),
1 => "1 console".to_string(),
n => format!("{n} consoles"),
};
format!(
" {} on {}, v{}, pid {}, {consoles}",
self.info.role.label(),
self.addr,
self.info.version,
self.info.pid
)
}
}
pub fn find(bind: SocketAddr) -> Vec<Running> {
let end = bind.port().saturating_add(PORT_HUNT_RANGE - 1);
(bind.port()..=end)
.map(|port| SocketAddr::new(bind.ip(), port))
.filter_map(|addr| {
let body = call(addr, "smon_info", "{}").ok()?;
let info: Info = serde_json::from_str(&body).ok()?;
Some(Running { addr, info })
})
.collect()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Gate {
Proceed,
Ask,
Refuse,
}
fn gate(running: &[Running], yes: bool, interactive: bool) -> Gate {
if running.is_empty() || yes {
Gate::Proceed
} else if interactive {
Gate::Ask
} else {
Gate::Refuse
}
}
pub fn confirm(running: &[Running], yes: bool) -> Result<()> {
let gate = gate(running, yes, stdin().is_terminal());
if gate == Gate::Proceed {
return Ok(());
}
println!("this update restarts or kills every smon running on this machine:");
for process in running {
println!("{}", process.describe());
}
println!("open sessions end and consoles drop while each one comes back.");
if gate == Gate::Refuse {
bail!("run it again with --yes to go ahead without being asked");
}
print!("continue? [y/N] ");
stdout().flush()?;
let mut answer = String::new();
stdin().read_line(&mut answer)?;
if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
bail!("cancelled, nothing was changed");
}
Ok(())
}
pub fn restart(running: &[Running], target: &Path) {
for process in running {
if let Some(exe) = process.info.exe.as_deref()
&& !same_file(exe, target)
{
println!(
"note: the {} on {} runs {}, not the binary just updated, so it comes back on {}",
process.info.role.label(),
process.addr,
exe.display(),
process.info.version
);
}
match call(process.addr, "smon_restart", "{}") {
Ok(_) => println!(
"asked the {} on {} to stand down",
process.info.role.label(),
process.addr
),
Err(error) => eprintln!(
"warning: could not stop the {} on {}: {error:#}",
process.info.role.label(),
process.addr
),
}
}
for process in running {
if process.info.role == Role::Daemon {
await_return(process);
}
}
}
fn await_return(process: &Running) {
let deadline = Instant::now() + RETURN_TIMEOUT;
while Instant::now() < deadline {
sleep(POLL);
let Ok(body) = call(process.addr, "smon_info", "{}") else {
continue;
};
let Ok(info) = serde_json::from_str::<Info>(&body) else {
continue;
};
if info.started == process.info.started {
continue;
}
println!("the daemon on {} is back on v{}", process.addr, info.version);
return;
}
eprintln!(
"warning: the daemon on {} did not come back within {}s, start it again by hand",
process.addr,
RETURN_TIMEOUT.as_secs()
);
}
fn same_file(left: &Path, right: &Path) -> bool {
let resolve = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
resolve(left) == resolve(right)
}
#[cfg(test)]
mod tests {
use std::{net::SocketAddr, path::PathBuf};
use super::{Gate, Running, gate, same_file};
use crate::control::{Info, Role};
fn running(role: Role, consoles: usize) -> Running {
Running {
addr: "127.0.0.1:4123".parse::<SocketAddr>().unwrap(),
info: Info {
version: "0.1.1".to_string(),
role,
pid: 42,
started: 1,
exe: Some(PathBuf::from("/home/x/.cargo/bin/smon")),
consoles,
},
}
}
#[test]
fn nothing_running_needs_no_confirmation() {
assert_eq!(gate(&[], false, true), Gate::Proceed);
assert_eq!(gate(&[], false, false), Gate::Proceed);
}
#[test]
fn the_yes_flag_skips_the_question() {
let live = [running(Role::Daemon, 2)];
assert_eq!(gate(&live, true, true), Gate::Proceed);
assert_eq!(gate(&live, true, false), Gate::Proceed);
}
#[test]
fn a_live_process_is_asked_about_on_a_terminal() {
assert_eq!(gate(&[running(Role::Tui, 1)], false, true), Gate::Ask);
}
#[test]
fn a_live_process_without_a_terminal_refuses() {
assert_eq!(gate(&[running(Role::Tui, 1)], false, false), Gate::Refuse);
}
#[test]
fn console_counts_read_as_plain_english() {
assert!(running(Role::Daemon, 0).describe().contains("no consoles"));
assert!(running(Role::Daemon, 1).describe().contains("1 console"));
assert!(running(Role::Daemon, 4).describe().contains("4 consoles"));
assert!(running(Role::Tui, 1).describe().contains("tui on 127.0.0.1:4123"));
}
#[test]
fn a_path_matches_itself_even_when_it_does_not_exist() {
let path = PathBuf::from("/nowhere/smon");
assert!(same_file(&path, &path));
assert!(!same_file(&path, &PathBuf::from("/elsewhere/smon")));
}
}