self-hosted-node 0.5.1

Headless manabrew engine node for hosting AI games
use std::time::Duration;

use tracing::{info, warn};

const DEFAULT_MANIFEST_URL: &str = "https://play.manabrew.app/manifest.json";
const DEFAULT_POLL_SECS: u64 = 300;
const NODE_VERSION: &str = env!("CARGO_PKG_VERSION");

pub struct StaleConfig {
    pub enabled: bool,
    pub manifest_url: String,
    pub poll: Duration,
}

impl StaleConfig {
    pub fn from_env_and_args() -> Self {
        let enabled = std::env::args().any(|arg| arg == "--shutdown-on-stale")
            || env_flag("SELF_HOSTED_NODE_SHUTDOWN_ON_STALE");
        let manifest_url = std::env::var("SELF_HOSTED_NODE_MANIFEST_URL")
            .unwrap_or_else(|_| DEFAULT_MANIFEST_URL.to_string());
        let poll = std::env::var("SELF_HOSTED_NODE_STALE_POLL_SECS")
            .ok()
            .and_then(|value| value.parse().ok())
            .map(Duration::from_secs)
            .unwrap_or(Duration::from_secs(DEFAULT_POLL_SECS));
        Self {
            enabled,
            manifest_url,
            poll,
        }
    }
}

const SHUTDOWN_GRACE: Duration = Duration::from_secs(10);

pub async fn run_stale_monitor<F, S>(config: StaleConfig, is_idle: F, shutdown_rooms: S)
where
    F: Fn() -> bool + Send + 'static,
    S: Fn() + Send + 'static,
{
    let mut tick = tokio::time::interval(config.poll);
    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
    loop {
        tick.tick().await;
        let Some(latest) = fetch_node_version(&config.manifest_url).await else {
            continue;
        };
        if !is_behind(NODE_VERSION, &latest) {
            continue;
        }
        if !config.enabled {
            warn!(
                current = NODE_VERSION,
                latest = %latest,
                manifest = %config.manifest_url,
                "self-hosted-node is OUT OF DATE — a newer build is published; restart on the latest release (enable --shutdown-on-stale to auto-exit when idle)"
            );
            continue;
        }
        if is_idle() {
            warn!(
                current = NODE_VERSION,
                latest = %latest,
                "self-hosted-node is stale and idle — exiting so the supervisor respawns on the latest build"
            );
            shutdown_rooms();
            tokio::time::sleep(SHUTDOWN_GRACE).await;
            std::process::exit(0);
        }
        info!(
            current = NODE_VERSION,
            latest = %latest,
            "self-hosted-node is stale but a game is in progress — deferring shutdown until idle"
        );
    }
}

async fn fetch_node_version(url: &str) -> Option<String> {
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(10))
        .build()
        .ok()?;
    let manifest: serde_json::Value = client.get(url).send().await.ok()?.json().await.ok()?;
    manifest
        .get("packages")?
        .get("self-hosted-node")?
        .as_str()
        .map(str::to_string)
}

fn is_behind(current: &str, latest: &str) -> bool {
    match (parse_semver(current), parse_semver(latest)) {
        (Some(current), Some(latest)) => latest > current,
        _ => false,
    }
}

fn parse_semver(version: &str) -> Option<(u64, u64, u64)> {
    let mut parts = version.split('.');
    let major = parts.next()?.parse::<u64>().ok()?;
    let minor = parts.next()?.parse::<u64>().ok()?;
    let patch = parts
        .next()
        .unwrap_or("0")
        .split('-')
        .next()?
        .parse::<u64>()
        .ok()?;
    Some((major, minor, patch))
}

fn env_flag(name: &str) -> bool {
    std::env::var(name)
        .map(|value| {
            matches!(
                value.to_ascii_lowercase().as_str(),
                "1" | "true" | "yes" | "on"
            )
        })
        .unwrap_or(false)
}