use crate::ChannelMessage;
use crate::util::UnwrapPoison;
use anyhow::{Context, Result, anyhow};
use std::ffi::OsStr;
use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::Mutex;
use tracing::{debug, error, info, warn};
pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION");
const HANDOFF_ENV: &str = "MAHBOT_UPDATE_HANDOFF";
const HANDOFF_LOCK_WAIT: Duration = Duration::from_secs(10);
const HANDOFF_POLL_INTERVAL: Duration = Duration::from_millis(100);
pub fn acquire_lock(storage_root: &Path) -> Result<()> {
let lock_path = crate::util::lock::lock_file_path(storage_root);
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
let file = match try_acquire_lock(&lock_path)? {
Some(file) => file,
None if std::env::var(HANDOFF_ENV).is_ok_and(|v| v == "1") => {
crate::boot::boot_diagnostic(format!(
"update hand-off: the instance lock is still held — waiting up to {}s for the \
previous instance to release it",
HANDOFF_LOCK_WAIT.as_secs()
));
wait_for_handoff(&lock_path)?
}
None => return Err(second_instance_refusal(&lock_path)),
};
info!(path = %lock_path.display(), "Acquired instance lock");
let guard = FlockGuard {
file: Some(file),
lock_path,
};
INSTANCE_LOCK
.set(Mutex::new(guard))
.expect("acquire_lock called more than once");
Ok(())
}
fn wait_for_handoff(lock_path: &Path) -> Result<File> {
let deadline = std::time::Instant::now() + HANDOFF_LOCK_WAIT;
while std::time::Instant::now() < deadline {
std::thread::sleep(HANDOFF_POLL_INTERVAL);
if let Some(file) = try_acquire_lock(lock_path)? {
return Ok(file);
}
}
Err(second_instance_refusal(lock_path))
}
fn second_instance_refusal(lock_path: &Path) -> anyhow::Error {
anyhow!(
"Another instance of mahbot is already running (lock file: {}). \
The lock is a kernel flock released automatically when that instance exits.",
lock_path.display()
)
}
fn try_acquire_lock(path: &Path) -> Result<Option<File>> {
let file = open_lock_file(path)
.with_context(|| format!("failed to open lock file {}", path.display()))?;
if crate::util::lock::try_flock(&file)
.with_context(|| format!("failed to lock {}", path.display()))?
{
Ok(Some(file))
} else {
Ok(None)
}
}
fn open_lock_file(path: &Path) -> std::io::Result<File> {
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
}
struct FlockGuard {
file: Option<File>,
lock_path: PathBuf,
}
impl std::fmt::Debug for FlockGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FlockGuard")
.field("held", &self.file.is_some())
.field("lock_path", &self.lock_path)
.finish()
}
}
impl FlockGuard {
fn release(&mut self) {
if let Some(file) = self.file.take() {
if let Err(e) = file.unlock() {
warn!(
error = %e,
path = %self.lock_path.display(),
"Failed to unlock instance lock file — the close still releases it"
);
}
info!(path = %self.lock_path.display(), "Released instance lock");
}
}
}
static INSTANCE_LOCK: OnceLock<Mutex<FlockGuard>> = OnceLock::new();
async fn release_instance_lock() {
if let Some(mutex) = INSTANCE_LOCK.get() {
let mut guard = mutex.lock().await;
guard.release();
}
}
async fn reacquire_instance_lock() -> Result<()> {
let mutex = INSTANCE_LOCK
.get()
.context("Instance lock not initialized")?;
let lock_path = {
let guard = mutex.lock().await;
if guard.file.is_some() {
return Ok(()); }
guard.lock_path.clone()
};
let file = try_acquire_lock(&lock_path)?;
let mut guard = mutex.lock().await;
match file {
Some(file) => {
info!(path = %guard.lock_path.display(), "Re-acquired instance lock");
guard.file = Some(file);
Ok(())
}
None => Err(anyhow!(
"Failed to re-acquire instance lock — another instance may have started"
)),
}
}
const RELEASE_REPO: &str = env!("CARGO_PKG_REPOSITORY");
const RELEASE_BASE_URL_ENV: &str = "MAHBOT_RELEASE_BASE_URL";
const UPDATE_TO_VERSION_ENV: &str = "MAHBOT_UPDATE_TO_VERSION";
const DOWNLOAD_TIMEOUT: Duration = Duration::from_mins(30);
const HOST_PROBE_TIMEOUT: Duration = Duration::from_secs(15);
fn release_base_url() -> String {
std::env::var(RELEASE_BASE_URL_ENV).unwrap_or_else(|_| RELEASE_REPO.to_string())
}
fn latest_version_url(base: &str) -> String {
format!("{base}/releases/latest/download/version.txt")
}
fn asset_name(version: &semver::Version, os: &str, arch: &str) -> String {
let extension = if os == "windows" { "zip" } else { "tar.gz" };
format!("mahbot-{version}-{os}-{arch}.{extension}")
}
fn asset_url(base: &str, version: &semver::Version, os: &str, arch: &str) -> String {
format!(
"{base}/releases/download/v{version}/{}",
asset_name(version, os, arch)
)
}
const MACOS_FLOOR: (u32, u32) = (12, 3);
const GLIBC_FLOOR: (u32, u32) = (2, 35);
const WINDOWS_X86_64_FLOOR: u32 = 17_763;
const WINDOWS_ARM64_FLOOR: u32 = 22_000;
const NO_RELEASE_FILE: &str = "MahBot has no file for this system";
const NO_GLIBC_FOUND: &str =
"the released Linux files are built for glibc, and no glibc was found on this system";
async fn absent_release_file() -> Option<String> {
if cfg!(target_os = "linux") {
let report = host_probe("getconf", &["GNU_LIBC_VERSION"]).await;
let musl_loader = crate::util::managed_bin::linux_host_is_musl();
if let Some(refusal) = linux_refusal(report.as_deref(), musl_loader) {
return Some(match refusal {
LinuxRefusal::GlibcBelowFloor => format!(
"{NO_RELEASE_FILE}: Linux with glibc {}.{} or newer is required",
GLIBC_FLOOR.0, GLIBC_FLOOR.1
),
LinuxRefusal::NoGlibc => format!("{NO_RELEASE_FILE}: {NO_GLIBC_FOUND}"),
});
}
}
if cfg!(target_os = "macos") {
let version = host_probe("sw_vers", &["-productVersion"]).await?;
if version_below_floor(&version, MACOS_FLOOR) {
return Some(format!(
"{NO_RELEASE_FILE}: macOS {}.{} or newer is required",
MACOS_FLOOR.0, MACOS_FLOOR.1
));
}
}
if cfg!(target_os = "windows") {
let (floor, requirement) = if cfg!(target_arch = "aarch64") {
(WINDOWS_ARM64_FLOOR, "Windows 11 on ARM")
} else {
(WINDOWS_X86_64_FLOOR, "Windows 10 version 1809")
};
if windows_build()? < floor {
return Some(format!(
"{NO_RELEASE_FILE}: {requirement} (build {floor}) or newer is required"
));
}
}
None
}
#[cfg(windows)]
fn windows_build() -> Option<u32> {
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
let mut info = unsafe { std::mem::zeroed::<OSVERSIONINFOW>() };
info.dwOSVersionInfoSize =
u32::try_from(std::mem::size_of_val(&info)).expect("a struct of a few words");
let status = unsafe { RtlGetVersion(&raw mut info) };
(status == 0 && info.dwBuildNumber != 0).then_some(info.dwBuildNumber)
}
#[cfg(not(windows))]
fn windows_build() -> Option<u32> {
None
}
async fn host_probe(program: &str, args: &[&str]) -> Option<String> {
let mut cmd = tokio::process::Command::new(program);
#[cfg(windows)]
cmd.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
let output = tokio::time::timeout(HOST_PROBE_TIMEOUT, cmd.args(args).output())
.await
.ok()?
.ok()?;
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
(output.status.success() && !text.is_empty()).then_some(text)
}
fn version_below_floor(version: &str, floor: (u32, u32)) -> bool {
let mut parts = version.trim().split('.');
let major = parts.next().and_then(|part| part.parse::<u32>().ok());
let minor = match parts.next() {
Some(part) => part.parse::<u32>().ok(),
None => Some(0),
};
match (major, minor) {
(Some(major), Some(minor)) => (major, minor) < floor,
_ => true,
}
}
fn names_glibc(report: &str) -> bool {
report.starts_with("glibc ")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LinuxRefusal {
GlibcBelowFloor,
NoGlibc,
}
fn linux_refusal(report: Option<&str>, musl_loader: bool) -> Option<LinuxRefusal> {
if report.is_some_and(glibc_below_floor) {
return Some(LinuxRefusal::GlibcBelowFloor);
}
(!report.is_some_and(names_glibc) && musl_loader).then_some(LinuxRefusal::NoGlibc)
}
fn glibc_below_floor(report: &str) -> bool {
report
.strip_prefix("glibc ")
.is_some_and(|version| version_below_floor(version, GLIBC_FLOOR))
}
fn current_version() -> Result<semver::Version> {
semver::Version::parse(VERSION)
.with_context(|| format!("embedded version {VERSION} is not valid semver"))
}
#[must_use]
fn update_target_override() -> Option<semver::Version> {
let named = std::env::var(UPDATE_TO_VERSION_ENV).ok()?;
let named = named.trim();
let named = semver::Version::parse(named.strip_prefix('v').unwrap_or(named)).ok()?;
let current = semver::Version::parse(VERSION).ok()?;
(named != current).then_some(named)
}
fn release_http_client() -> Result<&'static reqwest::Client> {
const CHECK_TIMEOUT: Duration = Duration::from_secs(15);
static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
CLIENT
.get_or_init(|| {
crate::util::http::install_ring_provider();
reqwest::Client::builder()
.user_agent(format!("mahbot/{VERSION} (self-update check)"))
.timeout(CHECK_TIMEOUT)
.build()
.map_err(|e| format!("failed to build the release-check HTTP client: {e}"))
})
.as_ref()
.map_err(|e| anyhow!("{e}"))
}
async fn fetch_latest_release_version() -> Result<Option<semver::Version>> {
let url = latest_version_url(&release_base_url());
let response = release_http_client()?
.get(&url)
.send()
.await
.context("failed to read the newest release version")?;
if !response.status().is_success() {
anyhow::bail!(
"the newest release version request returned HTTP {}",
response.status()
);
}
let body = response
.text()
.await
.context("failed to read the newest release version")?;
discoverable_version(&body)
}
fn discoverable_version(body: &str) -> Result<Option<semver::Version>> {
let version = semver::Version::parse(body.trim())
.context("the newest release version file does not hold a version")?;
Ok(version.pre.is_empty().then_some(version))
}
async fn check_download_update() -> Result<Option<semver::Version>> {
if let Some(named) = update_target_override() {
return Ok(Some(named));
}
let Some(latest) = fetch_latest_release_version().await? else {
return Ok(None);
};
Ok((latest > current_version()?).then_some(latest))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UpdateMode {
SourceTree,
Downloaded,
}
fn classify_update_mode(manifest_dir: &Path) -> UpdateMode {
let mut prev: Option<&std::ffi::OsStr> = None;
for component in manifest_dir.components() {
if let (Some(a), std::path::Component::Normal(b)) = (prev, component)
&& ((a == "registry" && b == "src") || (a == "git" && b == "checkouts"))
{
return UpdateMode::Downloaded;
}
prev = match component {
std::path::Component::Normal(os) => Some(os),
_ => None,
};
}
if manifest_dir.join(".git").exists() {
return UpdateMode::SourceTree;
}
if manifest_dir.join("Cargo.toml").is_file() {
UpdateMode::SourceTree
} else {
UpdateMode::Downloaded
}
}
#[must_use]
pub(crate) fn update_mode() -> UpdateMode {
classify_update_mode(Path::new(env!("CARGO_MANIFEST_DIR")))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct UpdateAvailability {
pub(crate) available: bool,
pub(crate) in_progress: bool,
}
struct UpdateCache {
available: AtomicBool,
latest: std::sync::Mutex<Option<semver::Version>>,
in_progress: AtomicBool,
}
static UPDATE_CACHE: OnceLock<UpdateCache> = OnceLock::new();
fn update_cache() -> &'static UpdateCache {
UPDATE_CACHE.get_or_init(|| UpdateCache {
available: AtomicBool::new(update_mode() == UpdateMode::SourceTree),
latest: std::sync::Mutex::new(None),
in_progress: AtomicBool::new(false),
})
}
#[must_use]
pub(crate) fn update_availability() -> UpdateAvailability {
let cache = update_cache();
UpdateAvailability {
available: cache.available.load(Ordering::SeqCst),
in_progress: cache.in_progress.load(Ordering::SeqCst),
}
}
#[must_use]
pub(crate) fn update_latest() -> Option<semver::Version> {
update_cache().latest.lock().unwrap_poison().clone()
}
#[must_use]
pub(crate) fn update_in_progress() -> bool {
update_availability().in_progress
}
#[must_use]
pub(crate) fn should_show_update(availability: UpdateAvailability) -> bool {
availability.available
}
#[cfg(test)]
pub(crate) struct UpdateCacheTestGuard {
previous: UpdateAvailability,
}
#[cfg(test)]
impl Drop for UpdateCacheTestGuard {
fn drop(&mut self) {
let cache = update_cache();
cache
.available
.store(self.previous.available, Ordering::SeqCst);
cache
.in_progress
.store(self.previous.in_progress, Ordering::SeqCst);
}
}
#[cfg(test)]
pub(crate) fn set_update_cache_for_test(
available: bool,
in_progress: bool,
) -> UpdateCacheTestGuard {
let previous = update_availability();
let cache = update_cache();
cache.available.store(available, Ordering::SeqCst);
cache.in_progress.store(in_progress, Ordering::SeqCst);
UpdateCacheTestGuard { previous }
}
async fn refresh_update_cache() {
let cache = update_cache();
match update_mode() {
UpdateMode::SourceTree => {
cache.available.store(true, Ordering::SeqCst);
}
UpdateMode::Downloaded => {
if cache.in_progress.load(Ordering::SeqCst) {
return;
}
let result = check_download_update().await;
if cache.in_progress.load(Ordering::SeqCst) {
return;
}
match result {
Ok(Some(latest)) => {
*cache.latest.lock().unwrap_poison() = Some(latest);
cache.available.store(true, Ordering::SeqCst);
}
Ok(None) => {
*cache.latest.lock().unwrap_poison() = None;
cache.available.store(false, Ordering::SeqCst);
}
Err(_) => {
}
}
}
}
}
pub async fn run_update_availability_refresh() {
loop {
refresh_update_cache().await;
if !crate::shutdown::sleep_or_shutdown_or_drain(Duration::from_mins(10)).await {
return;
}
}
}
static UPDATE_MUTEX: Mutex<()> = Mutex::const_new(());
pub(crate) const UPDATE_BUILD_COMPLETE_MSG: &str = "✅ Build complete.";
pub(crate) const UPDATE_DOWNLOAD_COMPLETE_MSG: &str = "✅ Update downloaded and put in place.";
pub(crate) const UPDATE_RESTART_MSG: &str =
"🔄 Waiting for work in progress to finish before the restart…";
const UPDATE_UNFINISHED_TARGET: &str = "mahbot::self_update";
const UPDATE_UNFINISHED_MESSAGE: &str = "the update could not be completed in full";
struct UnfinishedStep {
reason: &'static str,
stops_update: bool,
}
const STANDARD_DIR_UNUSABLE: UnfinishedStep = UnfinishedStep {
reason: "the standard per-user programs directory could not be written to",
stops_update: true,
};
const SECOND_COPY_REMAINS: UnfinishedStep = UnfinishedStep {
reason: "a second copy of the product could not be taken away",
stops_update: false,
};
async fn record_update_unfinished(admin_target: Option<&str>, step: UnfinishedStep) {
let Some(store) = crate::logs::LOG_STORE.get() else {
warn!(
reason = step.reason,
"Logs store is not up — an unfinished update cannot be recorded"
);
return;
};
match store
.has_reason(UPDATE_UNFINISHED_MESSAGE, step.reason)
.await
{
Ok(true) => return,
Ok(false) => {}
Err(e) => warn!(
error = %e,
"Could not read whether this unfinished update was already recorded — recording it again"
),
}
let entry = crate::logs::LogEntry {
timestamp: crate::db::now(),
level: "WARN".to_string(),
target: UPDATE_UNFINISHED_TARGET.to_string(),
message: UPDATE_UNFINISHED_MESSAGE.to_string(),
fields: serde_json::json!({ "reason": step.reason }),
..Default::default()
};
if let Err(e) = store.insert_batch(&[entry]).await {
warn!(error = %e, "Could not record an unfinished update");
return;
}
if step.stops_update {
return;
}
let message = format!("⚠️ {UPDATE_UNFINISHED_MESSAGE}: {}.", step.reason);
notify_admin(&message, admin_target).await;
}
static UPDATE_FINALIZING: AtomicBool = AtomicBool::new(false);
#[must_use]
pub fn update_is_finalizing() -> bool {
UPDATE_FINALIZING.load(Ordering::SeqCst)
}
async fn verify_cargo_on_path(action: &str) -> Result<()> {
let mut cmd = tokio::process::Command::new("cargo");
#[cfg(windows)]
cmd.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
let status = cmd
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
match status {
Ok(status) if status.success() => Ok(()),
_ => anyhow::bail!("cargo not found on PATH — cannot {action}"),
}
}
async fn resolve_update_admin_target() -> Option<String> {
let admin_target = resolve_admin_telegram_target().await;
if admin_target.is_none() {
if crate::config::CONFIG.telegram_bot_token().is_none() {
info!("No Telegram bot token configured — skipping update notifications");
} else {
warn!(
"Admin account 'admin' has no Telegram channel binding with a reply_target. \
Update notifications will be skipped. \
Bind a Telegram channel to the admin to receive update notifications."
);
}
}
admin_target
}
pub(crate) async fn execute_update() -> Result<()> {
let Some(_guard) = UPDATE_MUTEX.try_lock().ok() else {
anyhow::bail!("{UPDATE_IN_PROGRESS_MSG}");
};
update_cache().in_progress.store(true, Ordering::SeqCst);
let result = match update_mode() {
UpdateMode::SourceTree => execute_source_tree_update().await,
UpdateMode::Downloaded => execute_downloaded_update().await,
};
if result.is_err() {
update_cache().in_progress.store(false, Ordering::SeqCst);
}
result
}
pub async fn run_env_named_update() {
const SETTLE_DELAY: Duration = Duration::from_secs(15);
if update_target_override().is_none() || update_mode() != UpdateMode::Downloaded {
return;
}
if !crate::shutdown::sleep_or_shutdown_or_drain(SETTLE_DELAY).await {
return;
}
if let Err(e) = execute_update().await {
error!(error = %e, "The update to the version named in the environment did not complete");
}
}
async fn execute_source_tree_update() -> Result<()> {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let cargo_toml = manifest_dir.join("Cargo.toml");
if !cargo_toml.is_file() {
anyhow::bail!(
"Self-update is not available on this installation. \
Cargo.toml not found at {}. \
Self-update only works when running from the original build checkout directory.",
cargo_toml.display()
);
}
verify_cargo_on_path("build from source").await?;
let admin_target = resolve_update_admin_target().await;
notify_admin(
"🔄 Update started — building from source…",
admin_target.as_deref(),
)
.await;
let temp_install_root =
tempfile::tempdir().context("Failed to create temp install root for self-update")?;
let temp_build_dir =
tempfile::tempdir().context("Failed to create temp build dir for self-update")?;
let install_root = temp_install_root.path().to_path_buf();
let build_dir = temp_build_dir.path().to_path_buf();
run_cargo_with_timeout(
&[
OsStr::new("install"),
OsStr::new("--path"),
manifest_dir.as_os_str(),
OsStr::new("--root"),
install_root.as_os_str(),
OsStr::new("--locked"),
OsStr::new("--target-dir"),
build_dir.as_os_str(),
],
&crate::config::CONFIG.global_storage_root(),
Duration::from_hours(1),
"cargo install --path --locked",
&CargoStep {
toast_head: "Build failed",
admin_head: "Failed to build from source",
},
)
.await?;
let fresh_binary = temp_bin_path(&install_root);
finalize_source_tree_update(
&fresh_binary,
admin_target.as_deref(),
vec![install_root, build_dir],
)
.await
}
async fn execute_downloaded_update() -> Result<()> {
if let Some(reason) = absent_release_file().await {
anyhow::bail!("{reason} — no update was made.");
}
let target = if let Some(named) = update_target_override() {
named
} else {
let latest = fetch_latest_release_version().await?.ok_or_else(|| {
anyhow!("The newest published MahBot version could not be determined.")
})?;
let current = current_version()?;
anyhow::ensure!(
latest > current,
"The newest published MahBot version {latest} is not newer than the running \
{current} — no update was made."
);
latest
};
let admin_target = resolve_update_admin_target().await;
let Some(dest) = crate::util::managed_bin::mahbot_install_dir()
.map(|dir| dir.join(crate::util::managed_bin::product_file_name()))
else {
record_update_unfinished(admin_target.as_deref(), STANDARD_DIR_UNUSABLE).await;
anyhow::bail!("The standard install location could not be resolved — no update was made.");
};
notify_admin(
"🔄 Update started — downloading the new version…",
admin_target.as_deref(),
)
.await;
let temp_dir = tempfile::tempdir().context("Failed to create temp dir for self-update")?;
let (os, arch) = crate::util::managed_bin::host_os_arch().map_err(|e| anyhow!("{e}"))?;
let name = asset_name(&target, os, arch);
let url = asset_url(&release_base_url(), &target, os, arch);
let client = crate::util::http::build_download_client(DOWNLOAD_TIMEOUT)
.context("Failed to build the download client for self-update")?;
let archive = temp_dir.path().join(&name);
crate::util::http::download_verified(
&client,
&url,
&archive,
"",
Some(DOWNLOAD_TIMEOUT),
crate::util::http::DownloadSizeCheck::Exact,
|_, _| {},
)
.await
.with_context(|| format!("Failed to download {name}"))?;
let extract = if os == "windows" {
crate::util::managed_bin::extract_single_file_zip
} else {
crate::util::managed_bin::extract_single_file_tar_gz
};
let fresh = extract(
&archive,
temp_dir.path(),
crate::util::managed_bin::product_file_name(),
)
.map_err(|e| anyhow!("Failed to extract {name}: {e}"))?;
let current_exe = std::env::current_exe().context("Failed to resolve current_exe()")?;
let (spawn_path, relocated) = if canonicalize_safe(¤t_exe) == canonicalize_safe(&dest) {
if let Err(e) = self_replace::self_replace(&fresh) {
record_update_unfinished(admin_target.as_deref(), STANDARD_DIR_UNUSABLE).await;
return Err(e).with_context(|| format!("Failed to swap binary at {}", fresh.display()));
}
(current_exe, false)
} else {
if let Err(reason) = crate::util::managed_bin::place_extracted(&fresh, &dest) {
record_update_unfinished(admin_target.as_deref(), STANDARD_DIR_UNUSABLE).await;
anyhow::bail!("Could not put the new version at the standard location: {reason}");
}
(dest.clone(), true)
};
if relocated && let Err(e) = self_replace::self_delete() {
debug!(error = %e, "Could not remove the copy this instance came from");
record_update_unfinished(admin_target.as_deref(), SECOND_COPY_REMAINS).await;
}
if let Some(legacy) = legacy_copy(&dest)
&& let Err(e) = fs::remove_file(&legacy)
{
debug!(error = %e, "Could not remove the copy the old way of installing left behind");
record_update_unfinished(admin_target.as_deref(), SECOND_COPY_REMAINS).await;
}
notify_admin(UPDATE_DOWNLOAD_COMPLETE_MSG, admin_target.as_deref()).await;
notify_admin(UPDATE_RESTART_MSG, admin_target.as_deref()).await;
finalize_update_and_restart(&spawn_path, vec![temp_dir.path().to_path_buf()]).await
}
fn legacy_copy(dest: &Path) -> Option<PathBuf> {
let path = crate::util::cargo_bin_dir()?.join(crate::util::managed_bin::product_file_name());
if !path.is_file() {
return None;
}
let path = canonicalize_safe(&path);
if path == canonicalize_safe(&std::env::current_exe().ok()?) || path == canonicalize_safe(dest)
{
return None;
}
Some(path)
}
fn temp_bin_path(install_root: &Path) -> PathBuf {
install_root
.join("bin")
.join(crate::util::managed_bin::product_file_name())
}
async fn finalize_source_tree_update(
fresh_binary: &Path,
admin_target: Option<&str>,
cleanup_paths: Vec<PathBuf>,
) -> Result<()> {
let len = fs::metadata(fresh_binary)
.with_context(|| format!("Freshly built binary missing at {}", fresh_binary.display()))?
.len();
if len == 0 {
anyhow::bail!(
"Freshly built binary at {} is empty",
fresh_binary.display()
);
}
let current_exe = std::env::current_exe().context("Failed to resolve current_exe()")?;
self_replace::self_replace(fresh_binary)
.with_context(|| format!("Failed to swap binary at {}", fresh_binary.display()))?;
notify_admin(UPDATE_BUILD_COMPLETE_MSG, admin_target).await;
notify_admin(UPDATE_RESTART_MSG, admin_target).await;
finalize_update_and_restart(¤t_exe, cleanup_paths).await
}
async fn finalize_update_and_restart(spawn_path: &Path, cleanup_paths: Vec<PathBuf>) -> Result<()> {
UPDATE_FINALIZING.store(true, Ordering::SeqCst);
crate::shutdown::drain_begin();
let token = crate::shutdown::shutdown_token();
token.cancelled().await;
crate::tools::chrome_release::flush_and_close_all_chrome_sessions().await;
crate::db::checkpoint::checkpoint_all_databases().await;
for path in &cleanup_paths {
if let Err(e) = fs::remove_dir_all(path) {
warn!(
error = %e,
path = %path.display(),
"Could not remove temp update root"
);
}
}
release_instance_lock().await;
if let Err(e) = spawn_new_instance_from(spawn_path) {
UPDATE_FINALIZING.store(false, Ordering::SeqCst);
if let Err(lock_err) = reacquire_instance_lock().await {
error!(%lock_err, "Failed to re-acquire instance lock after spawn failure");
}
return Err(e);
}
crate::channels::chat_draft::flush_global();
std::process::exit(0);
}
struct CargoStep {
toast_head: &'static str,
admin_head: &'static str,
}
impl CargoStep {
fn failure(&self, body: String) -> anyhow::Error {
anyhow::Error::new(CargoStepFailure {
toast_head: self.toast_head,
admin_head: self.admin_head,
body,
})
}
}
#[derive(Debug)]
struct CargoStepFailure {
toast_head: &'static str,
admin_head: &'static str,
body: String,
}
impl std::fmt::Display for CargoStepFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}{}", self.toast_head, self.body)
}
}
impl std::error::Error for CargoStepFailure {}
async fn run_cargo_with_timeout(
args: &[&OsStr],
cwd: &Path,
timeout: Duration,
label: &str,
step: &CargoStep,
) -> Result<()> {
info!("Starting {label} in {}", cwd.display());
let mut cmd = tokio::process::Command::new("cargo");
#[cfg(windows)]
cmd.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
let cargo_result = tokio::time::timeout(
timeout,
cmd.args(args)
.current_dir(cwd)
.env_remove("CARGO_TARGET_DIR")
.kill_on_drop(true)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output(),
)
.await;
match cargo_result {
Err(_elapsed) => Err(step.failure(format!(
": {label} timed out after {} minutes",
timeout.as_secs() / 60
))),
Ok(Err(e)) => Err(step.failure(format!(": could not start cargo: {e}"))),
Ok(Ok(output)) if !output.status.success() => {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("stdout:\n{stdout}\nstderr:\n{stderr}");
let truncated = truncate_to_last_64k(&combined);
Err(step.failure(format!(":\n```\n{truncated}\n```")))
}
Ok(Ok(_)) => {
info!("{label} completed successfully");
Ok(())
}
}
}
pub async fn resolve_admin_telegram_target() -> Option<String> {
let _ = crate::config::CONFIG.telegram_bot_token()?;
let store = crate::users::store();
let bindings = store
.get_user_channels(crate::users::ADMIN_USER_NAME)
.await
.ok()?;
bindings
.into_iter()
.find(|b| b.channel == "telegram" && b.reply_target.is_some())
.and_then(|b| b.reply_target)
}
pub async fn notify_admin(message: &str, target: Option<&str>) {
let Some(recipient) = target else {
return;
};
if crate::channel_registry().get("telegram").is_none() {
warn!("Telegram channel not found in registry — cannot send update notification");
return;
}
if let Err(e) =
crate::channels::telegram::send_direct(recipient, message.to_string(), None).await
{
error!(error = %e, "Failed to send update notification to admin");
}
}
pub(crate) fn update_failure_notification(err: &anyhow::Error) -> String {
let statement = match err.downcast_ref::<CargoStepFailure>() {
Some(failure) => format!("{}{}", failure.admin_head, failure.body),
None => format!("{err:#}"),
};
format!("❌ {statement}")
}
pub const ADMIN_ONLY_CMD_MSG: &str = "This command is only available to the admin.";
const UPDATE_IN_PROGRESS_MSG: &str =
"An update is already in progress. Please wait for it to complete.";
pub async fn handle_update_command(msg: &ChannelMessage) {
if !crate::users::is_admin(&msg.user_name).await {
crate::channels::telegram::send_reply(&msg.reply_target, ADMIN_ONLY_CMD_MSG).await;
return;
}
let availability = update_availability();
if availability.in_progress {
crate::channels::telegram::send_reply(&msg.reply_target, UPDATE_IN_PROGRESS_MSG).await;
return;
}
if !should_show_update(availability) {
crate::channels::telegram::send_reply(
&msg.reply_target,
"No update is available at the moment.",
)
.await;
return;
}
if update_mode() == UpdateMode::SourceTree
&& let Err(e) = verify_cargo_on_path("perform the update").await
{
crate::channels::telegram::send_reply(
&msg.reply_target,
&format!("Cannot start the update: {e}"),
)
.await;
return;
}
if update_cache()
.in_progress
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
crate::channels::telegram::send_reply(&msg.reply_target, UPDATE_IN_PROGRESS_MSG).await;
return;
}
crate::channels::telegram::send_reply(
&msg.reply_target,
"✅ Update triggered — it will run in the background and restart the daemon when complete.",
)
.await;
let invoker_target = msg.reply_target.clone();
tokio::spawn(async move {
if let Err(e) = execute_update().await {
let failure = update_failure_notification(&e);
let admin_target = resolve_update_admin_target().await;
notify_admin(&failure, admin_target.as_deref()).await;
if admin_target.as_deref() != Some(invoker_target.as_str()) {
crate::channels::telegram::send_reply(&invoker_target, &failure).await;
}
}
});
}
fn canonicalize_safe(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn spawn_new_instance_from(binary_path: &Path) -> Result<()> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
info!(
program = %binary_path.display(),
args = ?args,
"Spawning new mahbot instance"
);
let mut cmd = std::process::Command::new(binary_path);
cmd.args(&args);
cmd.env(HANDOFF_ENV, "1");
let update_log = OpenOptions::new()
.create(true)
.append(true)
.open(
crate::config::CONFIG
.global_storage_root()
.join("update.log"),
)
.context("Failed to open update.log for child stderr")?;
cmd.stdin(Stdio::null()).stdout(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
}
cmd.stderr(Stdio::from(update_log));
match cmd.spawn() {
Ok(child) => {
info!(pid = child.id(), "Spawned new mahbot instance");
Ok(())
}
Err(e) => {
warn!(
error = %e,
"New instance spawn failed — keeping current instance alive"
);
Err(anyhow::Error::from(e).context("Failed to spawn new instance after update"))
}
}
}
fn truncate_to_last_64k(s: &str) -> String {
const MAX: usize = 64 * 1024;
if s.len() <= MAX {
return s.to_string();
}
let start = s.ceil_char_boundary(s.len() - MAX);
format!(
"[…output truncated; showing last {} bytes…]\n{}",
MAX,
&s[start..]
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::lock::{lock_file_path, try_flock};
#[test]
fn test_truncate_to_last_64k_no_truncation() {
let s = "hello world";
assert_eq!(truncate_to_last_64k(s), "hello world");
}
#[test]
fn test_truncate_to_last_64k_large_input() {
let big = "X".repeat(70_000);
let result = truncate_to_last_64k(&big);
assert!(result.starts_with("[…output truncated;"));
let x_count = result.chars().filter(|c| *c == 'X').count();
assert_eq!(x_count, 64 * 1024);
}
#[test]
fn test_try_acquire_lock_held_free() {
let dir = tempfile::tempdir().unwrap();
let lock_path = lock_file_path(dir.path());
let holder = open_lock_file(&lock_path).unwrap();
assert!(try_flock(&holder).unwrap(), "First flock should succeed");
let contender = open_lock_file(&lock_path).unwrap();
assert!(
!try_flock(&contender).unwrap(),
"Second flock should fail (already locked)"
);
assert!(
try_acquire_lock(&lock_path).unwrap().is_none(),
"Should return None when lock is held"
);
holder.unlock().expect("unlock the held lock file");
assert!(
try_flock(&contender).unwrap(),
"After the explicit unlock, the contender must acquire the lock"
);
drop(holder);
drop(contender);
assert!(
lock_becomes_free(&lock_path).expect("lock the released lock file"),
"After release, the lock must be acquirable again"
);
}
#[test]
fn wait_for_handoff_takes_the_released_location() {
let dir = tempfile::tempdir().unwrap();
let path = lock_file_path(dir.path());
let holder = open_lock_file(&path).unwrap();
assert!(
try_flock(&holder).unwrap(),
"the outgoing instance must hold the location"
);
let releaser = std::thread::spawn(move || {
std::thread::sleep(HANDOFF_POLL_INTERVAL * 2);
holder.unlock().expect("release the location");
holder
});
let taken =
wait_for_handoff(&path).expect("a marked instance must take the released location");
assert!(
location_is_held(&path),
"the wait must leave the location held"
);
drop(releaser.join().unwrap());
assert!(
location_is_held(&path),
"the lock taken by the wait must survive the outgoing handle's close"
);
drop(taken);
}
fn location_is_held(path: &Path) -> bool {
!try_flock(&open_lock_file(path).unwrap()).unwrap()
}
fn lock_becomes_free(path: &Path) -> Result<bool> {
for _ in 0..100 {
match try_acquire_lock(path) {
Ok(Some(_)) => return Ok(true),
Ok(None) => std::thread::sleep(Duration::from_millis(20)),
Err(e) => return Err(e),
}
}
Ok(false)
}
#[test]
fn test_lock_file_path_suffix() {
let dir = tempfile::tempdir().unwrap();
let path = lock_file_path(dir.path());
assert!(
path.ends_with("mahbot.lock"),
"Lock file path must end with mahbot.lock, got: {}",
path.display(),
);
}
#[test]
fn test_update_mode_detection() {
let dir = tempfile::tempdir().unwrap();
let checkout = dir.path().join("checkout");
std::fs::create_dir_all(checkout.join(".git")).unwrap();
std::fs::write(checkout.join("Cargo.toml"), "").unwrap();
assert_eq!(classify_update_mode(&checkout), UpdateMode::SourceTree);
let registry = dir
.path()
.join(".cargo")
.join("registry")
.join("src")
.join("index.crates.io-6f17d22bba3b01f9")
.join("mahbot-0.3.0");
std::fs::create_dir_all(®istry).unwrap();
std::fs::write(registry.join("Cargo.toml"), "").unwrap();
assert_eq!(classify_update_mode(®istry), UpdateMode::Downloaded);
let git_checkout = dir
.path()
.join(".cargo")
.join("git")
.join("checkouts")
.join("mahbot-1a2b3c")
.join("main");
std::fs::create_dir_all(git_checkout.join(".git")).unwrap();
std::fs::write(git_checkout.join("Cargo.toml"), "").unwrap();
assert_eq!(classify_update_mode(&git_checkout), UpdateMode::Downloaded);
let custom = dir
.path()
.join("custom-cargo")
.join("registry")
.join("src")
.join("index.crates.io-hash")
.join("mahbot-0.3.0");
std::fs::create_dir_all(&custom).unwrap();
std::fs::write(custom.join("Cargo.toml"), "").unwrap();
assert_eq!(classify_update_mode(&custom), UpdateMode::Downloaded);
let plain = dir.path().join("plain-src");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("Cargo.toml"), "").unwrap();
assert_eq!(classify_update_mode(&plain), UpdateMode::SourceTree);
let bare = dir.path().join("bare");
std::fs::create_dir_all(&bare).unwrap();
assert_eq!(classify_update_mode(&bare), UpdateMode::Downloaded);
}
#[test]
fn test_release_asset_naming_and_urls() {
let base = "https://example.test/owner/repo";
let version = semver::Version::new(1, 2, 3);
for (os, arch, name) in [
("macos", "x86_64", "mahbot-1.2.3-macos-x86_64.tar.gz"),
("macos", "aarch64", "mahbot-1.2.3-macos-aarch64.tar.gz"),
("linux", "x86_64", "mahbot-1.2.3-linux-x86_64.tar.gz"),
("linux", "aarch64", "mahbot-1.2.3-linux-aarch64.tar.gz"),
("windows", "x86_64", "mahbot-1.2.3-windows-x86_64.zip"),
("windows", "aarch64", "mahbot-1.2.3-windows-aarch64.zip"),
] {
assert_eq!(asset_name(&version, os, arch), name, "{os}-{arch}");
}
assert_eq!(
latest_version_url(base),
"https://example.test/owner/repo/releases/latest/download/version.txt"
);
assert_eq!(
asset_url(base, &version, "windows", "x86_64"),
"https://example.test/owner/repo/releases/download/v1.2.3/mahbot-1.2.3-windows-x86_64.zip"
);
let test_release = semver::Version::parse("1.2.3-rc.1").unwrap();
assert_eq!(
asset_url(base, &test_release, "linux", "aarch64"),
"https://example.test/owner/repo/releases/download/v1.2.3-rc.1/\
mahbot-1.2.3-rc.1-linux-aarch64.tar.gz"
);
}
#[test]
fn test_a_test_release_is_never_discovered() {
let stable = semver::Version::new(1, 2, 3);
assert_eq!(discoverable_version("1.2.3\n").unwrap(), Some(stable));
assert_eq!(discoverable_version("1.2.3-rc.1\n").unwrap(), None);
assert_eq!(discoverable_version(" 1.2.3-rc.1 ").unwrap(), None);
assert!(discoverable_version("").is_err());
assert!(discoverable_version("version 1.2.3").is_err());
}
#[test]
fn test_the_release_contract_is_spelled_the_same_everywhere() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let read = |name: &str| std::fs::read_to_string(root.join(name)).expect("part of the tree");
let (sh, ps1) = (read("install.sh"), read("install.ps1"));
for (name, script, base_var, version_var) in [
("install.sh", &sh, "$RELEASE_BASE", "$VERSION"),
("install.ps1", &ps1, "$ReleaseBase", "$Version"),
] {
assert!(script.contains(RELEASE_REPO), "{name} names {RELEASE_REPO}");
let pointer = latest_version_url(RELEASE_REPO).replace(RELEASE_REPO, base_var);
assert!(
script.contains(&pointer),
"{name} must look the newest release up at {pointer}"
);
let download = asset_url(
RELEASE_REPO,
&semver::Version::new(1, 0, 0),
"linux",
"x86_64",
)
.replace(RELEASE_REPO, base_var)
.replace("1.0.0", version_var);
let version_dir = download.rsplit_once('/').expect("an asset name").0;
assert!(
script.contains(version_dir),
"{name} must download a version from {version_dir}"
);
}
assert!(sh.contains(r#"ASSET="mahbot-$VERSION-$OS-$ARCH.tar.gz""#));
assert!(ps1.contains(r#"$Asset = "mahbot-$Version-windows-$Arch.zip""#));
assert!(sh.contains(r#"INSTALL_DIR="$HOME/.local/bin""#));
assert!(ps1.contains(r"'Programs\MahBot'"));
let install = crate::util::managed_bin::mahbot_install_dir().expect("a home directory");
#[cfg(unix)]
assert!(
install.ends_with(".local/bin"),
"the product's own install directory is {}",
install.display()
);
#[cfg(windows)]
assert!(
install.ends_with(r"Programs\MahBot"),
"the product's own install directory is {}",
install.display()
);
let workflow = read(".github/workflows/release.yml");
floors_are_spelled_the_same(&sh, &ps1, &workflow);
assert!(sh.contains(&format!("NO_FILE_PREFIX='{NO_RELEASE_FILE}'")));
assert!(ps1.contains(&format!("$NoFilePrefix = '{NO_RELEASE_FILE}'")));
assert!(sh.contains(NO_GLIBC_FOUND));
for (os, arch) in [
("macos", "x86_64"),
("macos", "aarch64"),
("linux", "x86_64"),
("linux", "aarch64"),
("windows", "x86_64"),
("windows", "aarch64"),
] {
let spelled =
asset_name(&semver::Version::new(1, 0, 0), os, arch).replace("1.0.0", "${VERSION}");
assert!(
workflow.contains(&format!("\"{spelled}\"")),
"release.yml must publish {spelled}"
);
}
}
fn floors_are_spelled_the_same(sh: &str, ps1: &str, workflow: &str) {
assert!(sh.contains(&format!("MACOS_FLOOR_MAJOR={}", MACOS_FLOOR.0)));
assert!(sh.contains(&format!("MACOS_FLOOR_MINOR={}", MACOS_FLOOR.1)));
assert!(sh.contains(&format!("GLIBC_FLOOR_MAJOR={}", GLIBC_FLOOR.0)));
assert!(sh.contains(&format!("GLIBC_FLOOR_MINOR={}", GLIBC_FLOOR.1)));
assert!(
workflow.contains(&format!(
"MACOSX_DEPLOYMENT_TARGET: ${{{{ matrix.target_os == 'macos' && '{}.{}' || '' }}}}",
MACOS_FLOOR.0, MACOS_FLOOR.1
)),
"the macOS files must be built against {}.{}",
MACOS_FLOOR.0,
MACOS_FLOOR.1
);
assert!(
workflow.contains(&format!("glibc {}.{}", GLIBC_FLOOR.0, GLIBC_FLOOR.1)),
"the Linux files must be built on a base whose glibc is the floor"
);
assert!(ps1.contains(&format!("$Build -lt {WINDOWS_X86_64_FLOOR}")));
assert!(ps1.contains(&format!("$Build -lt {WINDOWS_ARM64_FLOOR}")));
assert!(ps1.contains(&format!("Windows 11 (build {WINDOWS_ARM64_FLOOR})")));
assert!(ps1.contains(&format!(
"Windows 10 version 1809 (build {WINDOWS_X86_64_FLOOR})"
)));
}
#[test]
fn floor_checks_compare_whole_version_numbers() {
assert!(version_below_floor("2.4", GLIBC_FLOOR));
assert!(!version_below_floor("2.39", GLIBC_FLOOR));
assert!(version_below_floor("12", MACOS_FLOOR));
assert!(!version_below_floor("12.3", MACOS_FLOOR));
assert!(!version_below_floor("12.3.1", MACOS_FLOOR));
assert!(!version_below_floor("13", MACOS_FLOOR));
assert!(version_below_floor("", MACOS_FLOOR));
assert!(version_below_floor("12.", MACOS_FLOOR));
assert!(version_below_floor("12.x", MACOS_FLOOR));
assert!(version_below_floor("13.x", MACOS_FLOOR));
assert!(glibc_below_floor("glibc 2.31"));
assert!(!glibc_below_floor("glibc 2.35"));
assert!(!glibc_below_floor("musl libc (x86_64)"));
}
#[test]
fn a_linux_host_is_refused_by_its_own_evidence() {
use LinuxRefusal::{GlibcBelowFloor, NoGlibc};
assert_eq!(
linux_refusal(Some("glibc 2.31"), false),
Some(GlibcBelowFloor)
);
assert_eq!(
linux_refusal(Some("glibc 2.31"), true),
Some(GlibcBelowFloor)
);
assert_eq!(linux_refusal(Some("glibc 2.35"), true), None);
assert_eq!(linux_refusal(Some("glibc 2.39"), true), None);
assert_eq!(linux_refusal(None, true), Some(NoGlibc));
assert_eq!(
linux_refusal(Some("musl libc (x86_64)"), true),
Some(NoGlibc)
);
assert_eq!(linux_refusal(None, false), None);
assert_eq!(linux_refusal(Some("musl libc (x86_64)"), false), None);
}
#[test]
fn the_leftover_is_never_the_file_just_put_in_place() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let copy = bin.join(crate::util::managed_bin::product_file_name());
std::fs::write(©, b"a copy").unwrap();
let _cargo_home = crate::util::test::set_env_var(
"CARGO_HOME",
Some(dir.path().to_str().expect("a UTF-8 temp path")),
);
let placed = legacy_copy(©);
let elsewhere = legacy_copy(&bin.join("somewhere_else"));
assert_eq!(placed, None, "the file just placed is not a leftover");
assert_eq!(
elsewhere,
Some(canonicalize_safe(©)),
"a copy that is neither is a leftover"
);
}
#[test]
fn test_canonicalize_safe_nonexistent_path() {
let dir = tempfile::tempdir().unwrap();
let nonexistent = dir.path().join("does_not_exist");
let result = canonicalize_safe(&nonexistent);
assert_eq!(result, nonexistent);
}
#[test]
fn test_canonicalize_safe_existing_path() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test_file.txt");
std::fs::write(&file_path, "hello").unwrap();
let result = canonicalize_safe(&file_path);
assert!(
result.ends_with("test_file.txt"),
"Canonicalized path should end with test_file.txt, got {}",
result.display(),
);
}
}