Skip to main content

self_hosted_node/
updater.rs

1use std::time::Duration;
2
3use tracing::{info, warn};
4
5const DEFAULT_MANIFEST_URL: &str = "https://play.manabrew.app/manifest.json";
6const DEFAULT_POLL_SECS: u64 = 300;
7const NODE_VERSION: &str = env!("CARGO_PKG_VERSION");
8
9pub struct StaleConfig {
10    pub enabled: bool,
11    pub manifest_url: String,
12    pub poll: Duration,
13}
14
15impl StaleConfig {
16    pub fn from_env_and_args() -> Self {
17        let enabled = std::env::args().any(|arg| arg == "--shutdown-on-stale")
18            || env_flag("SELF_HOSTED_NODE_SHUTDOWN_ON_STALE");
19        let manifest_url = std::env::var("SELF_HOSTED_NODE_MANIFEST_URL")
20            .unwrap_or_else(|_| DEFAULT_MANIFEST_URL.to_string());
21        let poll = std::env::var("SELF_HOSTED_NODE_STALE_POLL_SECS")
22            .ok()
23            .and_then(|value| value.parse().ok())
24            .map(Duration::from_secs)
25            .unwrap_or(Duration::from_secs(DEFAULT_POLL_SECS));
26        Self {
27            enabled,
28            manifest_url,
29            poll,
30        }
31    }
32}
33
34const SHUTDOWN_GRACE: Duration = Duration::from_secs(10);
35
36pub async fn run_stale_monitor<F, S>(config: StaleConfig, is_idle: F, shutdown_rooms: S)
37where
38    F: Fn() -> bool + Send + 'static,
39    S: Fn() + Send + 'static,
40{
41    let mut tick = tokio::time::interval(config.poll);
42    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
43    loop {
44        tick.tick().await;
45        let Some(latest) = fetch_node_version(&config.manifest_url).await else {
46            continue;
47        };
48        if !is_behind(NODE_VERSION, &latest) {
49            continue;
50        }
51        if !config.enabled {
52            warn!(
53                current = NODE_VERSION,
54                latest = %latest,
55                manifest = %config.manifest_url,
56                "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)"
57            );
58            continue;
59        }
60        if is_idle() {
61            warn!(
62                current = NODE_VERSION,
63                latest = %latest,
64                "self-hosted-node is stale and idle — exiting so the supervisor respawns on the latest build"
65            );
66            shutdown_rooms();
67            tokio::time::sleep(SHUTDOWN_GRACE).await;
68            std::process::exit(0);
69        }
70        info!(
71            current = NODE_VERSION,
72            latest = %latest,
73            "self-hosted-node is stale but a game is in progress — deferring shutdown until idle"
74        );
75    }
76}
77
78async fn fetch_node_version(url: &str) -> Option<String> {
79    let client = reqwest::Client::builder()
80        .timeout(Duration::from_secs(10))
81        .build()
82        .ok()?;
83    let manifest: serde_json::Value = client.get(url).send().await.ok()?.json().await.ok()?;
84    manifest
85        .get("packages")?
86        .get("self-hosted-node")?
87        .as_str()
88        .map(str::to_string)
89}
90
91fn is_behind(current: &str, latest: &str) -> bool {
92    match (parse_semver(current), parse_semver(latest)) {
93        (Some(current), Some(latest)) => latest > current,
94        _ => false,
95    }
96}
97
98fn parse_semver(version: &str) -> Option<(u64, u64, u64)> {
99    let mut parts = version.split('.');
100    let major = parts.next()?.parse::<u64>().ok()?;
101    let minor = parts.next()?.parse::<u64>().ok()?;
102    let patch = parts
103        .next()
104        .unwrap_or("0")
105        .split('-')
106        .next()?
107        .parse::<u64>()
108        .ok()?;
109    Some((major, minor, patch))
110}
111
112fn env_flag(name: &str) -> bool {
113    std::env::var(name)
114        .map(|value| {
115            matches!(
116                value.to_ascii_lowercase().as_str(),
117                "1" | "true" | "yes" | "on"
118            )
119        })
120        .unwrap_or(false)
121}