Skip to main content

hatchery_node/
machine_env.rs

1//! Collect host machine resources + installed provider CLI versions for topology inventory.
2//!
3//! Snapshots are cached: Snapshot / ListSessions used to call this on every request, and six
4//! `sh -c "command -v …"` + `--version` probes took ~700ms and held the bridge request queue,
5//! so Resize (view attach) after Spawn never landed before the provisional grace expired.
6
7use hatchery_core::{MachineEnvironment, ProviderCliReport};
8use std::fs;
9use std::process::Command;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Mutex, OnceLock};
12use std::time::{Duration, Instant};
13
14const PROVIDERS: &[&str] = &["claude", "codex", "grok", "kimi", "kimi-cli", "bash"];
15
16/// How long a cached environment snapshot is served before a background refresh.
17const CACHE_TTL: Duration = Duration::from_secs(60);
18
19struct Cached {
20    env: MachineEnvironment,
21    at: Instant,
22}
23
24fn cache() -> &'static Mutex<Option<Cached>> {
25    static CACHE: OnceLock<Mutex<Option<Cached>>> = OnceLock::new();
26    CACHE.get_or_init(|| Mutex::new(None))
27}
28
29fn refreshing() -> &'static AtomicBool {
30    static FLAG: OnceLock<AtomicBool> = OnceLock::new();
31    FLAG.get_or_init(|| AtomicBool::new(false))
32}
33
34/// Snapshot the host environment. Best-effort: missing fields become 0 / empty.
35///
36/// Fast path: serve a cached copy. A stale cache kicks off one background refresh so the
37/// bridge request worker never blocks on `sh` / `--version`. First call still collects sync.
38pub fn collect() -> MachineEnvironment {
39    {
40        let guard = cache().lock().unwrap_or_else(|p| p.into_inner());
41        if let Some(c) = guard.as_ref() {
42            let age = c.at.elapsed();
43            let env = c.env.clone();
44            drop(guard);
45            if age >= CACHE_TTL {
46                kick_refresh();
47            }
48            return env;
49        }
50    }
51    let env = collect_fresh();
52    *cache().lock().unwrap_or_else(|p| p.into_inner()) = Some(Cached {
53        env: env.clone(),
54        at: Instant::now(),
55    });
56    env
57}
58
59fn kick_refresh() {
60    if refreshing()
61        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
62        .is_err()
63    {
64        return;
65    }
66    std::thread::Builder::new()
67        .name("machine-env-refresh".into())
68        .spawn(|| {
69            let env = collect_fresh();
70            *cache().lock().unwrap_or_else(|p| p.into_inner()) = Some(Cached {
71                env,
72                at: Instant::now(),
73            });
74            refreshing().store(false, Ordering::SeqCst);
75        })
76        .ok();
77}
78
79fn collect_fresh() -> MachineEnvironment {
80    let (os, kernel, arch) = uname();
81    let hostname = fs::read_to_string("/etc/hostname")
82        .or_else(|_| fs::read_to_string("/proc/sys/kernel/hostname"))
83        .map(|s| s.trim().to_owned())
84        .unwrap_or_else(|_| "unknown".into());
85    let uptime_secs = fs::read_to_string("/proc/uptime")
86        .ok()
87        .and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
88        .map(|s| s as u64)
89        .unwrap_or(0);
90    let cpu_cores = std::thread::available_parallelism()
91        .map(|n| n.get() as u32)
92        .unwrap_or(1);
93    let cpu_model = cpu_model();
94    let (ram_total_bytes, ram_available_bytes) = meminfo();
95    let (disk_total_bytes, disk_available_bytes) = disk_root();
96    let provider_clis = PROVIDERS
97        .iter()
98        .filter_map(|id| probe_cli(id))
99        .collect();
100    MachineEnvironment {
101        os,
102        kernel,
103        arch,
104        hostname,
105        uptime_secs,
106        cpu_cores,
107        cpu_model,
108        ram_total_bytes,
109        ram_available_bytes,
110        disk_total_bytes,
111        disk_available_bytes,
112        provider_clis,
113    }
114}
115
116fn uname() -> (String, String, String) {
117    let out = hidden(Command::new("uname")).args(["-srm"]).output().ok();
118    let text = out
119        .and_then(|o| String::from_utf8(o.stdout).ok())
120        .unwrap_or_default();
121    let mut parts = text.split_whitespace();
122    let os = parts.next().unwrap_or(std::env::consts::OS).to_owned();
123    let kernel = parts.next().unwrap_or("unknown").to_owned();
124    let arch = parts
125        .next()
126        .unwrap_or(std::env::consts::ARCH)
127        .to_owned();
128    (os, kernel, arch)
129}
130
131fn cpu_model() -> Option<String> {
132    let text = fs::read_to_string("/proc/cpuinfo").ok()?;
133    for line in text.lines() {
134        if let Some(rest) = line.strip_prefix("model name") {
135            return Some(rest.trim_start_matches(|c: char| c == ':' || c.is_whitespace()).to_owned());
136        }
137    }
138    None
139}
140
141fn meminfo() -> (u64, u64) {
142    let text = match fs::read_to_string("/proc/meminfo") {
143        Ok(t) => t,
144        Err(_) => return (0, 0),
145    };
146    let mut total_kb = 0u64;
147    let mut avail_kb = 0u64;
148    for line in text.lines() {
149        if let Some(v) = line.strip_prefix("MemTotal:") {
150            total_kb = parse_kb(v);
151        } else if let Some(v) = line.strip_prefix("MemAvailable:") {
152            avail_kb = parse_kb(v);
153        }
154    }
155    (total_kb.saturating_mul(1024), avail_kb.saturating_mul(1024))
156}
157
158fn parse_kb(v: &str) -> u64 {
159    v.split_whitespace()
160        .next()
161        .and_then(|n| n.parse().ok())
162        .unwrap_or(0)
163}
164
165fn disk_root() -> (u64, u64) {
166    // Prefer `statvfs` via df -B1 /
167    let out = hidden(Command::new("df")).args(["-B1", "/"]).output().ok();
168    let text = out.and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
169    for line in text.lines().skip(1) {
170        let cols: Vec<_> = line.split_whitespace().collect();
171        if cols.len() >= 4 {
172            let total = cols[1].parse().unwrap_or(0);
173            let avail = cols[3].parse().unwrap_or(0);
174            return (total, avail);
175        }
176    }
177    (0, 0)
178}
179
180fn probe_cli(id: &str) -> Option<ProviderCliReport> {
181    let path = hidden(Command::new("sh"))
182        .args(["-c", &format!("command -v {id}")])
183        .output()
184        .ok()
185        .and_then(|o| {
186            if o.status.success() {
187                String::from_utf8(o.stdout).ok().map(|s| s.trim().to_owned())
188            } else {
189                None
190            }
191        })
192        .filter(|s| !s.is_empty())?;
193    let version = hidden(Command::new(&path))
194        .arg("--version")
195        .output()
196        .ok()
197        .and_then(|o| {
198            let mut text = String::from_utf8_lossy(&o.stdout).to_string();
199            if text.trim().is_empty() {
200                text = String::from_utf8_lossy(&o.stderr).to_string();
201            }
202            let line = text.lines().next()?.trim().to_owned();
203            if line.is_empty() { None } else { Some(line) }
204        });
205    Some(ProviderCliReport {
206        id: id.to_owned(),
207        version,
208        path: Some(path),
209    })
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn collect_returns_nonzero_cores_on_linux() {
218        let env = collect();
219        assert!(env.cpu_cores >= 1);
220        assert!(!env.os.is_empty());
221        assert!(!env.arch.is_empty());
222    }
223
224    #[test]
225    fn second_collect_is_cached_and_fast() {
226        let _ = collect();
227        let t0 = Instant::now();
228        let _ = collect();
229        assert!(
230            t0.elapsed() < Duration::from_millis(50),
231            "cached collect must not re-probe CLIs"
232        );
233    }
234}
235
236/// Every probe child must not pop a console window on Windows.
237fn hidden(mut command: Command) -> Command {
238    crate::windows_process::hide_console(&mut command);
239    command
240}