mermaid_cli/ollama/server.rs
1//! Local Ollama server lifecycle — detect a dead loopback server and start it.
2//!
3//! The product rule: the user should never have to leave mermaid to run
4//! `ollama serve`. When a request to a *local* Ollama URL is refused, we
5//! locate the binary, start `ollama serve` detached (it outlives mermaid and
6//! ignores the TUI's Ctrl+C), wait for the URL to become healthy, and let the
7//! caller retry. Remote URLs are never touched — you can't start a server on
8//! someone else's machine.
9//!
10//! Only *intent* paths auto-start (chat, model listing the user asked for,
11//! startup preflight). Diagnostics (`mermaid status` / `doctor`) observe
12//! without healing — see the `autostart` flag their `BackendConfig`s set.
13//! Runtime kill-switch: `MERMAID_OLLAMA_AUTOSTART=0` disables autostart
14//! process-wide (containers/CI where spawning a GPU server is unwanted);
15//! unit-test builds are hard-disabled so no test can ever spawn a real
16//! server through a default-config adapter.
17//!
18//! Concurrency: attempts are serialized process-wide behind a tokio `Mutex`,
19//! and a failed attempt is remembered for a short cooldown so concurrent
20//! callers (chat + model list on a cold boot) can't spawn-storm. A marker is
21//! armed at spawn time too, so a caller cancelled mid-wait (Esc during a
22//! turn) can't let the next caller double-spawn against a still-booting
23//! child. Holding the lock across awaits is deliberate; a cancelled caller
24//! drops its future, releases the lock, and leaves the spawned server
25//! running — the server is a system resource, not turn-scoped work.
26
27use std::path::PathBuf;
28use std::process::Stdio;
29use std::time::{Duration, Instant};
30
31use crate::utils::classify_host;
32
33/// How long a failed start attempt suppresses new attempts. Long enough that
34/// the retry storm of a single turn (chat + probes) collapses into one
35/// attempt, short enough that "I just installed Ollama, try again" works.
36const COOLDOWN: Duration = Duration::from_secs(15);
37/// How long a freshly spawned `ollama serve` gets to become reachable.
38/// Cold start (GPU discovery included) is typically 1–5s.
39const STARTUP_DEADLINE: Duration = Duration::from_secs(15);
40const POLL_INTERVAL: Duration = Duration::from_millis(300);
41
42/// Why autostart couldn't produce a healthy server.
43#[derive(Debug, Clone)]
44pub enum AutostartError {
45 /// The URL isn't loopback — autostart doesn't apply. Callers should
46 /// surface their original connection error untouched.
47 NotLocal,
48 /// Autostart is switched off for this process (`MERMAID_OLLAMA_AUTOSTART=0`
49 /// or a unit-test build). Same pass-through contract as `NotLocal`.
50 Disabled,
51 /// No `ollama` binary on PATH or in the platform's default install
52 /// locations.
53 NotInstalled,
54 /// A start was attempted (or recently attempted) but the URL never became
55 /// reachable; carries the specific failure.
56 Unhealthy(String),
57}
58
59impl AutostartError {
60 /// Human hint to append to the caller's connection error, or `None` when
61 /// the error should pass through untouched (`NotLocal` / `Disabled`).
62 pub fn hint(&self) -> Option<String> {
63 match self {
64 AutostartError::NotLocal | AutostartError::Disabled => None,
65 AutostartError::NotInstalled => Some(
66 "Ollama doesn't appear to be installed (not on PATH or in the default \
67 install locations) — install it from https://ollama.com/download"
68 .to_string(),
69 ),
70 AutostartError::Unhealthy(detail) => Some(format!("auto-start failed: {detail}")),
71 }
72 }
73}
74
75/// Serialized attempt state: the last failed attempt and its error, kept for
76/// [`COOLDOWN`] so repeated connection failures don't re-spawn in a loop.
77struct AttemptState {
78 last_failure: Option<(Instant, AutostartError)>,
79}
80
81/// Process-wide single-flight + cooldown. Deliberately NOT keyed by URL:
82/// every Ollama adapter in this process derives its URL from the single
83/// `config.ollama.host:port`, so there is exactly one authority to guard. If
84/// mermaid ever grows multi-endpoint Ollama support, key this by authority.
85static STATE: std::sync::LazyLock<tokio::sync::Mutex<AttemptState>> =
86 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(AttemptState { last_failure: None }));
87
88/// Runtime kill-switch (see module docs). `cfg!(test)` hard-disables in unit
89/// tests so a default-config adapter (`ollama_autostart: true` pointing at
90/// localhost) can never start a real server on a contributor machine.
91fn autostart_disabled() -> bool {
92 cfg!(test) || std::env::var_os("MERMAID_OLLAMA_AUTOSTART").is_some_and(|v| v == "0")
93}
94
95/// Make sure a *local* Ollama server is listening at `base_url`, starting
96/// `ollama serve` if needed. `Ok(())` means the URL answered a health probe
97/// (whether it was already up or we just started it) and a retry is
98/// worthwhile.
99pub async fn ensure_running(base_url: &str) -> Result<(), AutostartError> {
100 let authority = authority_of(base_url).to_string();
101 if !classify_host(host_of(&authority)).is_loopback() {
102 return Err(AutostartError::NotLocal);
103 }
104 if autostart_disabled() {
105 return Err(AutostartError::Disabled);
106 }
107 let Ok(client) = reqwest::Client::builder()
108 .timeout(Duration::from_secs(1))
109 .build()
110 else {
111 return Err(AutostartError::Unhealthy(
112 "could not build a health-probe HTTP client".to_string(),
113 ));
114 };
115
116 let mut state = STATE.lock().await;
117 // Probe FIRST, cooldown second: another caller may have revived the
118 // server while we waited for the lock, and a server the user started by
119 // hand must be picked up instantly even inside the cooldown window.
120 if healthy(&client, base_url).await {
121 state.last_failure = None;
122 return Ok(());
123 }
124 if let Some((at, err)) = &state.last_failure
125 && at.elapsed() < COOLDOWN
126 {
127 return Err(err.clone());
128 }
129
130 let outcome = start_and_wait(&mut state, &client, base_url, &authority).await;
131 state.last_failure = match &outcome {
132 Ok(()) => None,
133 Err(e) => Some((Instant::now(), e.clone())),
134 };
135 outcome
136}
137
138/// Locate the binary, spawn `ollama serve` detached, and poll `base_url`
139/// until it answers or the deadline passes.
140async fn start_and_wait(
141 state: &mut AttemptState,
142 client: &reqwest::Client,
143 base_url: &str,
144 authority: &str,
145) -> Result<(), AutostartError> {
146 let Some(binary) = find_binary() else {
147 return Err(AutostartError::NotInstalled);
148 };
149 tracing::info!(
150 binary = %binary.display(),
151 authority,
152 "ollama is not running — starting `ollama serve`"
153 );
154 let mut child = spawn_serve(&binary, authority).map_err(|e| {
155 AutostartError::Unhealthy(format!(
156 "could not launch `{} serve`: {e}",
157 binary.display()
158 ))
159 })?;
160 // Arm the cooldown NOW, not only on failure: if our caller is cancelled
161 // mid-wait (future dropped, lock released), the next caller must see a
162 // recent attempt and wait out the boot instead of double-spawning a
163 // second `serve` that just loses the port bind. The health-probe-first
164 // order above still picks the booted server up instantly.
165 state.last_failure = Some((
166 Instant::now(),
167 AutostartError::Unhealthy(
168 "`ollama serve` was started moments ago and may still be coming up — retry shortly"
169 .to_string(),
170 ),
171 ));
172
173 let deadline = Instant::now() + STARTUP_DEADLINE;
174 loop {
175 if healthy(client, base_url).await {
176 tracing::info!(%base_url, "ollama serve is up");
177 return Ok(());
178 }
179 // A dead child means it will never become healthy — report the exit
180 // instead of polling out the full deadline (also reaps the process,
181 // so no zombie lingers on unix).
182 if let Ok(Some(status)) = child.try_wait() {
183 return Err(AutostartError::Unhealthy(format!(
184 "`ollama serve` exited immediately ({status}) — is another server \
185 holding the port, or is OLLAMA_HOST misconfigured?"
186 )));
187 }
188 if Instant::now() >= deadline {
189 return Err(AutostartError::Unhealthy(format!(
190 "started `ollama serve` but {base_url} was not reachable within {}s",
191 STARTUP_DEADLINE.as_secs()
192 )));
193 }
194 tokio::time::sleep(POLL_INTERVAL).await;
195 }
196}
197
198/// One cheap liveness probe: `GET /api/version` with the client's short
199/// timeout.
200async fn healthy(client: &reqwest::Client, base_url: &str) -> bool {
201 let url = format!("{}/api/version", base_url);
202 matches!(client.get(&url).send().await, Ok(r) if r.status().is_success())
203}
204
205/// Spawn `ollama serve` detached: null stdio, its own process group (so the
206/// TUI's Ctrl+C doesn't kill it), no console on Windows. The server
207/// deliberately outlives mermaid — it's a shared system service, and killing
208/// it on exit would break other Ollama clients.
209fn spawn_serve(binary: &std::path::Path, authority: &str) -> std::io::Result<std::process::Child> {
210 let mut cmd = std::process::Command::new(binary);
211 cmd.arg("serve")
212 // Bind exactly where mermaid expects the server. An inherited
213 // OLLAMA_HOST pointing somewhere else (e.g. 0.0.0.0 for LAN
214 // exposure) would start a server we then can't reach at `base_url`;
215 // users who want a custom bind manage the server themselves and can
216 // set `auto_start = false`.
217 .env("OLLAMA_HOST", authority)
218 .stdin(Stdio::null())
219 .stdout(Stdio::null())
220 .stderr(Stdio::null());
221 #[cfg(unix)]
222 {
223 use std::os::unix::process::CommandExt;
224 cmd.process_group(0);
225 }
226 #[cfg(windows)]
227 {
228 use std::os::windows::process::CommandExt;
229 cmd.creation_flags(crate::utils::DETACHED_PROCESS | crate::utils::CREATE_NEW_PROCESS_GROUP);
230 }
231 cmd.spawn()
232}
233
234/// `ollama` from PATH, falling back to the platform installer's default
235/// locations (PATH edits don't reach already-running shells, and the macOS
236/// app bundle never touches PATH). Also the definition of "installed" used
237/// by `detector::is_installed`, so the startup preflight and the autostart
238/// can never disagree about whether Ollama exists.
239pub(crate) fn find_binary() -> Option<PathBuf> {
240 if let Ok(path) = which::which("ollama") {
241 return Some(path);
242 }
243 known_install_paths().into_iter().find(|p| p.is_file())
244}
245
246#[cfg(target_os = "windows")]
247fn known_install_paths() -> Vec<PathBuf> {
248 let mut paths = Vec::new();
249 if let Some(base) = std::env::var_os("LOCALAPPDATA") {
250 paths.push(
251 PathBuf::from(base)
252 .join("Programs")
253 .join("Ollama")
254 .join("ollama.exe"),
255 );
256 }
257 if let Some(base) = std::env::var_os("ProgramFiles") {
258 paths.push(PathBuf::from(base).join("Ollama").join("ollama.exe"));
259 }
260 paths
261}
262
263#[cfg(target_os = "macos")]
264fn known_install_paths() -> Vec<PathBuf> {
265 vec![
266 PathBuf::from("/opt/homebrew/bin/ollama"),
267 PathBuf::from("/usr/local/bin/ollama"),
268 PathBuf::from("/Applications/Ollama.app/Contents/Resources/ollama"),
269 ]
270}
271
272#[cfg(all(unix, not(target_os = "macos")))]
273fn known_install_paths() -> Vec<PathBuf> {
274 vec![
275 PathBuf::from("/usr/local/bin/ollama"),
276 PathBuf::from("/usr/bin/ollama"),
277 ]
278}
279
280/// `http://localhost:11434` → `localhost:11434` (scheme and any path/query
281/// stripped). The adapter's `normalize_url` guarantees a scheme is present,
282/// but parse defensively.
283fn authority_of(base_url: &str) -> &str {
284 let rest = base_url
285 .split_once("://")
286 .map(|(_, rest)| rest)
287 .unwrap_or(base_url);
288 rest.split(['/', '?', '#']).next().unwrap_or(rest)
289}
290
291/// Host part of an authority: `localhost:11434` → `localhost`,
292/// `[::1]:11434` → `[::1]` (brackets kept; `classify_host` strips them).
293/// Note: whether `ollama serve` itself accepts a bracketed IPv6 OLLAMA_HOST
294/// is unverified — IPv6-loopback autostart is best-effort.
295fn host_of(authority: &str) -> &str {
296 if let Some(end) = authority.rfind(']') {
297 return &authority[..=end];
298 }
299 authority.split(':').next().unwrap_or(authority)
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn authority_strips_scheme_and_path() {
308 assert_eq!(authority_of("http://localhost:11434"), "localhost:11434");
309 assert_eq!(authority_of("http://127.0.0.1:11434/v1"), "127.0.0.1:11434");
310 assert_eq!(
311 authority_of("https://ollama.example.com/api?x=1"),
312 "ollama.example.com"
313 );
314 assert_eq!(authority_of("localhost:11434"), "localhost:11434");
315 }
316
317 #[test]
318 fn host_extracts_from_authority() {
319 assert_eq!(host_of("localhost:11434"), "localhost");
320 assert_eq!(host_of("127.0.0.1:8080"), "127.0.0.1");
321 assert_eq!(host_of("[::1]:11434"), "[::1]");
322 assert_eq!(host_of("localhost"), "localhost");
323 }
324
325 #[tokio::test]
326 async fn remote_urls_are_never_started() {
327 // The whole gate: autostart must refuse to act for a non-loopback
328 // URL — no spawn, no health probe against third parties. (LAN/private
329 // hosts count as remote too: mermaid can't start a server there.)
330 // Checked BEFORE the test-build kill-switch, so this asserts the real
331 // production gate order.
332 for url in [
333 "https://ollama.example.com",
334 "http://192.168.1.50:11434",
335 "http://10.0.0.7:11434",
336 ] {
337 match ensure_running(url).await {
338 Err(AutostartError::NotLocal) => {},
339 other => panic!("{url} must be NotLocal, got {other:?}"),
340 }
341 }
342 }
343
344 #[tokio::test]
345 async fn test_builds_never_spawn_even_for_loopback() {
346 // The cfg!(test) hard-off: a loopback URL in a unit-test build stops
347 // at Disabled before the lock/probe/spawn machinery. This is what
348 // makes default-config adapters (autostart=true, localhost:11434)
349 // safe in every present and future test on machines with Ollama
350 // installed.
351 match ensure_running("http://127.0.0.1:11434").await {
352 Err(AutostartError::Disabled) => {},
353 other => panic!("expected Disabled in test builds, got {other:?}"),
354 }
355 }
356
357 #[test]
358 fn hints_are_actionable_and_passthrough_variants_are_silent() {
359 assert!(AutostartError::NotLocal.hint().is_none());
360 assert!(AutostartError::Disabled.hint().is_none());
361 let not_installed = AutostartError::NotInstalled.hint().expect("hint");
362 assert!(not_installed.contains("https://ollama.com/download"));
363 let unhealthy = AutostartError::Unhealthy("boom".into())
364 .hint()
365 .expect("hint");
366 assert!(unhealthy.contains("boom"));
367 }
368
369 #[test]
370 fn install_candidates_exist_per_platform() {
371 // Shape check only — never spawns. Windows may legitimately return an
372 // empty list if the env vars are unset; unix lists are static.
373 let paths = known_install_paths();
374 #[cfg(not(target_os = "windows"))]
375 assert!(!paths.is_empty());
376 for p in paths {
377 assert!(p.to_string_lossy().to_lowercase().contains("ollama"));
378 }
379 }
380}