Skip to main content

netget/scripting/
environment.rs

1//! Script runtime environment detection
2
3use super::types::ScriptLanguage;
4use std::sync::OnceLock;
5use std::time::Duration;
6use tracing::warn;
7#[cfg(not(target_arch = "wasm32"))]
8use tracing::{debug, info};
9
10/// How long a single `<runtime> --version` probe may take before it is treated as absent.
11///
12/// `Command::output()` has no timeout: it reads the child's stdout to EOF, and if the child
13/// never exits, the calling thread blocks **forever**. That is not theoretical here. A
14/// `--test-threads=100` run wedged completely with 98 of 100 threads parked behind one
15/// initialising `AppState::new`, and the initialiser's stack was
16/// `detect → detect_javascript → Command::output → read_output`. In production the same stack
17/// hangs `netget --mcp` at startup, before it can report anything, if `node` or `python3` is
18/// wedged on the machine.
19///
20/// Five seconds is far more than a version probe needs and short enough that an operator sees
21/// a startup, not a hang.
22const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
23
24/// Detected once per process. The answer is a property of the MACHINE, not of an `AppState`.
25///
26/// `AppState::new()` used to run all four probes, and every test and every server creation
27/// calls it — so a 100-thread suite spawned four hundred subprocesses to learn the same four
28/// facts, and any one of them hanging stalled everything behind it.
29static DETECTED: OnceLock<ScriptingEnvironment> = OnceLock::new();
30
31/// Information about available scripting environments
32#[derive(Debug, Clone, Default)]
33pub struct ScriptingEnvironment {
34    /// Python availability and version
35    pub python: Option<String>,
36
37    /// JavaScript (Node.js) availability and version
38    pub javascript: Option<String>,
39
40    /// Go availability and version
41    pub go: Option<String>,
42
43    /// Perl availability and version
44    pub perl: Option<String>,
45}
46
47impl ScriptingEnvironment {
48    /// Detect available scripting environments
49    /// The process-wide detection result, computed on first call.
50    ///
51    /// Prefer this over [`Self::detect_uncached`] everywhere: runtimes do not appear and
52    /// disappear while NetGet runs, and probing per `AppState` costs four subprocess spawns
53    /// each time for an answer that cannot have changed.
54    pub fn detect() -> Self {
55        DETECTED.get_or_init(Self::detect_uncached).clone()
56    }
57
58    /// Run the probes. Public for the one caller that genuinely wants to re-probe.
59    pub fn detect_uncached() -> Self {
60        #[cfg(target_arch = "wasm32")]
61        {
62            Self::default()
63        }
64        #[cfg(not(target_arch = "wasm32"))]
65        {
66            Self::detect_native()
67        }
68    }
69
70    #[cfg(not(target_arch = "wasm32"))]
71    fn detect_native() -> Self {
72        debug!("Detecting scripting environments...");
73        // A slow interpreter does not multiply startup latency by four.
74        let (python, javascript, go, perl) = std::thread::scope(|scope| {
75            let python = scope.spawn(Self::detect_python);
76            let javascript = scope.spawn(Self::detect_javascript);
77            let go = scope.spawn(Self::detect_go);
78            let perl = scope.spawn(Self::detect_perl);
79            (
80                python.join().unwrap_or_default(),
81                javascript.join().unwrap_or_default(),
82                go.join().unwrap_or_default(),
83                perl.join().unwrap_or_default(),
84            )
85        });
86
87        info!("Scripting environment detection:");
88        if let Some(ref ver) = python {
89            info!("  Python: {} ✓", ver);
90        } else {
91            info!("  Python: not available");
92        }
93        if let Some(ref ver) = javascript {
94            info!("  Node.js: {} ✓", ver);
95        } else {
96            info!("  Node.js: not available");
97        }
98        if let Some(ref ver) = go {
99            info!("  Go: {} ✓", ver);
100        } else {
101            info!("  Go: not available");
102        }
103        if let Some(ref ver) = perl {
104            info!("  Perl: {} ✓", ver);
105        } else {
106            info!("  Perl: not available");
107        }
108
109        Self {
110            python,
111            javascript,
112            go,
113            perl,
114        }
115    }
116
117    /// Run `<program> --version` with a deadline, returning its trimmed output.
118    ///
119    /// The child is killed if it outlives [`PROBE_TIMEOUT`], because the alternative is
120    /// `Command::output()`'s behaviour: block on the stdout pipe until the child exits, with
121    /// no way out.
122    pub fn probe(program: &str, args: &[&str]) -> Option<String> {
123        Self::probe_with_timeout(program, args, PROBE_TIMEOUT)
124    }
125
126    /// Explicit budget is useful for callers probing a custom runtime.
127    pub fn probe_with_timeout(program: &str, args: &[&str], timeout: Duration) -> Option<String> {
128        #[cfg(target_arch = "wasm32")]
129        {
130            let _ = (program, args, timeout);
131            None
132        }
133        #[cfg(not(target_arch = "wasm32"))]
134        {
135            let program = program.to_owned();
136            let args = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
137            // This sync API may be called from a Tokio worker. Own a separate
138            // runtime rather than nesting block_on in the caller's runtime.
139            std::thread::Builder::new()
140                .name("netget-runtime-probe".into())
141                .spawn(move || {
142                    let runtime = tokio::runtime::Builder::new_current_thread()
143                        .enable_all()
144                        .build()
145                        .ok()?;
146                    runtime.block_on(async move {
147                        use super::process_io::{read_bounded, ProcessGroup};
148                        use std::process::Stdio;
149                        let mut command = tokio::process::Command::new(&program);
150                        command
151                            .args(&args)
152                            .stdin(Stdio::null())
153                            .stdout(Stdio::piped())
154                            .stderr(Stdio::piped())
155                            .kill_on_drop(true);
156                        ProcessGroup::configure(&mut command);
157                        let mut child = command.spawn().ok()?;
158                        let group = ProcessGroup::new(&child).ok()?;
159                        let stdout = child.stdout.take()?;
160                        let stderr = child.stderr.take()?;
161                        let outcome = tokio::time::timeout(timeout, async {
162                            tokio::try_join!(
163                                read_bounded(stdout, 64 * 1024),
164                                read_bounded(stderr, 64 * 1024),
165                                child.wait()
166                            )
167                        })
168                        .await;
169                        group.kill();
170                        let _ = child.start_kill();
171                        let _ = tokio::time::timeout(Duration::from_secs(1), child.wait()).await;
172                        let (stdout, stderr, status) = outcome.ok()?.ok()?;
173                        if !status.success() {
174                            return None;
175                        }
176                        let output = if stdout.is_empty() { stderr } else { stdout };
177                        let text = String::from_utf8_lossy(&output).trim().to_string();
178                        (!text.is_empty()).then_some(text)
179                    })
180                })
181                .ok()?
182                .join()
183                .ok()
184                .flatten()
185        }
186    }
187
188    #[cfg(not(target_arch = "wasm32"))]
189    fn detect_python() -> Option<String> {
190        Self::probe("python3", &["--version"]).inspect(|v| debug!("python3 detected: {v}"))
191    }
192
193    /// Detect Node.js availability and version
194    #[cfg(not(target_arch = "wasm32"))]
195    fn detect_javascript() -> Option<String> {
196        Self::probe("node", &["--version"]).inspect(|v| debug!("node detected: {v}"))
197    }
198
199    /// Detect Go availability and version
200    #[cfg(not(target_arch = "wasm32"))]
201    fn detect_go() -> Option<String> {
202        Self::probe("go", &["version"]).inspect(|v| debug!("go detected: {v}"))
203    }
204
205    /// Detect Perl availability and version
206    #[cfg(not(target_arch = "wasm32"))]
207    fn detect_perl() -> Option<String> {
208        Self::probe("perl", &["--version"]).inspect(|v| debug!("perl detected: {v}"))
209    }
210
211    /// Check if a specific language is available
212    pub fn is_available(&self, language: ScriptLanguage) -> bool {
213        match language {
214            ScriptLanguage::Python => self.python.is_some(),
215            ScriptLanguage::JavaScript => self.javascript.is_some(),
216            ScriptLanguage::Go => self.go.is_some(),
217            ScriptLanguage::Perl => self.perl.is_some(),
218        }
219    }
220
221    /// Get version string for a language
222    pub fn get_version(&self, language: ScriptLanguage) -> Option<&str> {
223        match language {
224            ScriptLanguage::Python => self.python.as_deref(),
225            ScriptLanguage::JavaScript => self.javascript.as_deref(),
226            ScriptLanguage::Go => self.go.as_deref(),
227            ScriptLanguage::Perl => self.perl.as_deref(),
228        }
229    }
230
231    /// Format available environments for display to user/LLM
232    pub fn format_available(&self) -> String {
233        let mut parts = Vec::new();
234
235        if let Some(ref ver) = self.python {
236            parts.push(format!("Python ({})", ver));
237        }
238        if let Some(ref ver) = self.javascript {
239            parts.push(format!("Node.js ({})", ver));
240        }
241        if let Some(ref ver) = self.go {
242            parts.push(format!("Go ({})", ver));
243        }
244        if let Some(ref ver) = self.perl {
245            parts.push(format!("Perl ({})", ver));
246        }
247
248        if parts.is_empty() {
249            "None".to_string()
250        } else {
251            parts.join(", ")
252        }
253    }
254
255    /// Warn if language is not available
256    pub fn warn_if_unavailable(&self, language: ScriptLanguage) {
257        if !self.is_available(language) {
258            warn!(
259                "{} is not available on this system. Scripts using {} will fail.",
260                language.as_str(),
261                language.as_str()
262            );
263        }
264    }
265}