self_hosted_node/
updater.rs1use 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}