Skip to main content

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 mermaid_model::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
59/// The [`mermaid_model::models::adapters::ollama::LocalServerRecovery`] the model layer is handed when the user's config
60/// allows autostart.
61///
62/// This is the whole inversion: `ensure_running` — process discovery, spawning,
63/// health-polling — lives here, in the module that owns the Ollama process, and
64/// the wire adapter receives it as a capability instead of reaching up for it.
65/// An adapter constructed without one cannot start anything, which is what makes
66/// the enumeration verbs (`list`, `status`, `doctor`, `/model`) read-only by
67/// construction rather than by a `bool` they remember to pass.
68pub struct OllamaAutostart;
69
70#[async_trait::async_trait]
71impl mermaid_model::models::adapters::ollama::LocalServerRecovery for OllamaAutostart {
72    async fn ensure_running(
73        &self,
74        base_url: &str,
75        notify: Option<&(dyn for<'a> Fn(&'a str) + Sync)>,
76    ) -> std::result::Result<(), Option<String>> {
77        // `hint()` already encodes "nothing useful to say" as `None` for the
78        // pass-through cases (NotLocal / Disabled), so the mapping is total.
79        ensure_running(base_url, notify).await.map_err(|e| e.hint())
80    }
81}
82
83impl AutostartError {
84    /// Human hint to append to the caller's connection error, or `None` when
85    /// the error should pass through untouched (`NotLocal` / `Disabled`).
86    #[must_use]
87    pub fn hint(&self) -> Option<String> {
88        match self {
89            Self::NotLocal | Self::Disabled => None,
90            Self::NotInstalled => Some(
91                "Ollama doesn't appear to be installed (not on PATH or in the default \
92                 install locations) — install it from https://ollama.com/download"
93                    .to_string(),
94            ),
95            Self::Unhealthy(detail) => Some(format!("auto-start failed: {detail}")),
96        }
97    }
98}
99
100/// Serialized attempt state: the last failed attempt and its error, kept for
101/// [`COOLDOWN`] so repeated connection failures don't re-spawn in a loop.
102struct AttemptState {
103    last_failure: Option<(Instant, AutostartError)>,
104}
105
106/// Process-wide single-flight + cooldown. Deliberately NOT keyed by URL:
107/// every Ollama adapter in this process derives its URL from the single
108/// `config.ollama.host:port`, so there is exactly one authority to guard. If
109/// mermaid ever grows multi-endpoint Ollama support, key this by authority.
110static STATE: std::sync::LazyLock<tokio::sync::Mutex<AttemptState>> =
111    std::sync::LazyLock::new(|| tokio::sync::Mutex::new(AttemptState { last_failure: None }));
112
113/// Runtime kill-switch (see module docs). `cfg!(test)` hard-disables in unit
114/// tests so a default-config adapter (`ollama_autostart: true` pointing at
115/// localhost) can never start a real server on a contributor machine.
116fn autostart_disabled() -> bool {
117    cfg!(test) || std::env::var_os("MERMAID_OLLAMA_AUTOSTART").is_some_and(|v| v == "0")
118}
119
120/// The single user-visible line surfaced at the moment a start is actually
121/// attempted. Owned here (next to the spawn) so every trigger path — TUI
122/// chat, headless `mermaid run`, CLI model list — shows identical wording,
123/// and so it can say the part users can't otherwise discover: the server is
124/// detached and deliberately outlives mermaid.
125pub const STARTING_NOTICE: &str =
126    "Starting the local Ollama server (it stays running after mermaid exits)…";
127
128/// Make sure a *local* Ollama server is listening at `base_url`, starting
129/// `ollama serve` if needed. `Ok(())` means the URL answered a health probe
130/// (whether it was already up or we just started it) and a retry is
131/// worthwhile.
132///
133/// `notify` is invoked with [`STARTING_NOTICE`] exactly once, at the moment
134/// a spawn is committed to — never for `NotLocal`/`Disabled`, never when the
135/// probe finds the server already healthy, never when the binary is missing.
136/// Callers route it to their user-visible surface (stream status line,
137/// stderr); the up-to-15s wait behind a generic spinner, the invisible
138/// detached process, and file-only tracing are otherwise all silent.
139///
140/// # Errors
141///
142/// [`AutostartError::NotLocal`] when `base_url` is not loopback — a remote
143/// Ollama is never ours to start; [`AutostartError::Disabled`] when autostart
144/// is turned off; and [`AutostartError::Unhealthy`] when the probe client
145/// cannot be built, the binary is missing, or the spawned server does not
146/// answer within the wait. A failure is cached for a cooldown window and
147/// returned verbatim to callers inside it, so a repeated `Err` need not mean a
148/// repeated spawn attempt.
149pub async fn ensure_running(
150    base_url: &str,
151    notify: Option<&(dyn for<'a> Fn(&'a str) + Sync)>,
152) -> Result<(), AutostartError> {
153    let authority = authority_of(base_url).to_string();
154    if !classify_host(host_of(&authority)).is_loopback() {
155        return Err(AutostartError::NotLocal);
156    }
157    if autostart_disabled() {
158        return Err(AutostartError::Disabled);
159    }
160    let Ok(client) = reqwest::Client::builder()
161        .timeout(Duration::from_secs(1))
162        .build()
163    else {
164        return Err(AutostartError::Unhealthy(
165            "could not build a health-probe HTTP client".to_string(),
166        ));
167    };
168
169    let mut state = STATE.lock().await;
170    // Probe FIRST, cooldown second: another caller may have revived the
171    // server while we waited for the lock, and a server the user started by
172    // hand must be picked up instantly even inside the cooldown window.
173    if healthy(&client, base_url).await {
174        state.last_failure = None;
175        return Ok(());
176    }
177    if let Some((at, err)) = &state.last_failure
178        && at.elapsed() < COOLDOWN
179    {
180        return Err(err.clone());
181    }
182
183    let outcome = start_and_wait(&mut state, &client, base_url, &authority, notify).await;
184    state.last_failure = match &outcome {
185        Ok(()) => None,
186        Err(e) => Some((Instant::now(), e.clone())),
187    };
188    outcome
189}
190
191/// Locate the binary, spawn `ollama serve` detached, and poll `base_url`
192/// until it answers or the deadline passes.
193async fn start_and_wait(
194    state: &mut AttemptState,
195    client: &reqwest::Client,
196    base_url: &str,
197    authority: &str,
198    notify: Option<&(dyn for<'a> Fn(&'a str) + Sync)>,
199) -> Result<(), AutostartError> {
200    let Some(binary) = find_binary() else {
201        return Err(AutostartError::NotInstalled);
202    };
203    // The spawn is committed — this is the one moment the user hears about
204    // it (tracing below lands in the log file, invisible in normal use).
205    if let Some(notify) = notify {
206        notify(STARTING_NOTICE);
207    }
208    tracing::info!(
209        binary = %binary.display(),
210        authority,
211        "ollama is not running — starting `ollama serve`"
212    );
213    let mut child = spawn_serve(&binary, authority).map_err(|e| {
214        AutostartError::Unhealthy(format!(
215            "could not launch `{} serve`: {e}",
216            binary.display()
217        ))
218    })?;
219    // Arm the cooldown NOW, not only on failure: if our caller is cancelled
220    // mid-wait (future dropped, lock released), the next caller must see a
221    // recent attempt and wait out the boot instead of double-spawning a
222    // second `serve` that just loses the port bind. The health-probe-first
223    // order above still picks the booted server up instantly.
224    state.last_failure = Some((
225        Instant::now(),
226        AutostartError::Unhealthy(
227            "`ollama serve` was started moments ago and may still be coming up — retry shortly"
228                .to_string(),
229        ),
230    ));
231
232    let deadline = Instant::now() + STARTUP_DEADLINE;
233    loop {
234        if healthy(client, base_url).await {
235            tracing::info!(%base_url, "ollama serve is up");
236            return Ok(());
237        }
238        // A dead child means it will never become healthy — report the exit
239        // instead of polling out the full deadline (also reaps the process,
240        // so no zombie lingers on unix).
241        if let Ok(Some(status)) = child.try_wait() {
242            return Err(AutostartError::Unhealthy(format!(
243                "`ollama serve` exited immediately ({status}) — is another server \
244                 holding the port, or is OLLAMA_HOST misconfigured?"
245            )));
246        }
247        if Instant::now() >= deadline {
248            return Err(AutostartError::Unhealthy(format!(
249                "started `ollama serve` but {base_url} was not reachable within {}s",
250                STARTUP_DEADLINE.as_secs()
251            )));
252        }
253        tokio::time::sleep(POLL_INTERVAL).await;
254    }
255}
256
257/// One cheap liveness probe: `GET /api/version` with the client's short
258/// timeout.
259async fn healthy(client: &reqwest::Client, base_url: &str) -> bool {
260    let url = format!("{base_url}/api/version");
261    matches!(client.get(&url).send().await, Ok(r) if r.status().is_success())
262}
263
264/// Spawn `ollama serve` detached: null stdio, its own process group (so the
265/// TUI's Ctrl+C doesn't kill it), no console on Windows. The server
266/// deliberately outlives mermaid — it's a shared system service, and killing
267/// it on exit would break other Ollama clients.
268fn spawn_serve(binary: &std::path::Path, authority: &str) -> std::io::Result<std::process::Child> {
269    let mut cmd = std::process::Command::new(binary);
270    cmd.arg("serve")
271        // Bind exactly where mermaid expects the server. An inherited
272        // OLLAMA_HOST pointing somewhere else (e.g. 0.0.0.0 for LAN
273        // exposure) would start a server we then can't reach at `base_url`;
274        // users who want a custom bind manage the server themselves and can
275        // set `auto_start = false`.
276        .env("OLLAMA_HOST", authority)
277        .stdin(Stdio::null())
278        .stdout(Stdio::null())
279        .stderr(Stdio::null());
280    #[cfg(unix)]
281    {
282        use std::os::unix::process::CommandExt;
283        cmd.process_group(0);
284    }
285    #[cfg(windows)]
286    {
287        use std::os::windows::process::CommandExt;
288        // CREATE_NO_WINDOW, never DETACHED_PROCESS: the latter leaves a
289        // visible console window on Windows 11 (see utils::proc).
290        cmd.creation_flags(
291            mermaid_model::utils::CREATE_NO_WINDOW | mermaid_model::utils::CREATE_NEW_PROCESS_GROUP,
292        );
293    }
294    cmd.spawn()
295}
296
297/// `ollama` from PATH, falling back to the platform installer's default
298/// locations (PATH edits don't reach already-running shells, and the macOS
299/// app bundle never touches PATH). Also the definition of "installed" used
300/// by `detector::is_installed`, so the startup preflight and the autostart
301/// can never disagree about whether Ollama exists.
302pub(crate) fn find_binary() -> Option<PathBuf> {
303    if let Ok(path) = which::which("ollama") {
304        return Some(path);
305    }
306    known_install_paths().into_iter().find(|p| p.is_file())
307}
308
309#[cfg(target_os = "windows")]
310fn known_install_paths() -> Vec<PathBuf> {
311    let mut paths = Vec::new();
312    if let Some(base) = std::env::var_os("LOCALAPPDATA") {
313        paths.push(
314            PathBuf::from(base)
315                .join("Programs")
316                .join("Ollama")
317                .join("ollama.exe"),
318        );
319    }
320    if let Some(base) = std::env::var_os("ProgramFiles") {
321        paths.push(PathBuf::from(base).join("Ollama").join("ollama.exe"));
322    }
323    paths
324}
325
326#[cfg(target_os = "macos")]
327fn known_install_paths() -> Vec<PathBuf> {
328    vec![
329        PathBuf::from("/opt/homebrew/bin/ollama"),
330        PathBuf::from("/usr/local/bin/ollama"),
331        PathBuf::from("/Applications/Ollama.app/Contents/Resources/ollama"),
332    ]
333}
334
335#[cfg(all(unix, not(target_os = "macos")))]
336fn known_install_paths() -> Vec<PathBuf> {
337    vec![
338        PathBuf::from("/usr/local/bin/ollama"),
339        PathBuf::from("/usr/bin/ollama"),
340    ]
341}
342
343/// `http://localhost:11434` → `localhost:11434` (scheme and any path/query
344/// stripped). The adapter's `normalize_url` guarantees a scheme is present,
345/// but parse defensively.
346fn authority_of(base_url: &str) -> &str {
347    let rest = base_url
348        .split_once("://")
349        .map(|(_, rest)| rest)
350        .unwrap_or(base_url);
351    rest.split(['/', '?', '#']).next().unwrap_or(rest)
352}
353
354/// Host part of an authority: `localhost:11434` → `localhost`,
355/// `[::1]:11434` → `[::1]` (brackets kept; `classify_host` strips them).
356/// Note: whether `ollama serve` itself accepts a bracketed IPv6 `OLLAMA_HOST`
357/// is unverified — IPv6-loopback autostart is best-effort.
358fn host_of(authority: &str) -> &str {
359    if let Some(end) = authority.rfind(']') {
360        return &authority[..=end];
361    }
362    authority.split(':').next().unwrap_or(authority)
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn authority_strips_scheme_and_path() {
371        assert_eq!(authority_of("http://localhost:11434"), "localhost:11434");
372        assert_eq!(authority_of("http://127.0.0.1:11434/v1"), "127.0.0.1:11434");
373        assert_eq!(
374            authority_of("https://ollama.example.com/api?x=1"),
375            "ollama.example.com"
376        );
377        assert_eq!(authority_of("localhost:11434"), "localhost:11434");
378    }
379
380    #[test]
381    fn host_extracts_from_authority() {
382        assert_eq!(host_of("localhost:11434"), "localhost");
383        assert_eq!(host_of("127.0.0.1:8080"), "127.0.0.1");
384        assert_eq!(host_of("[::1]:11434"), "[::1]");
385        assert_eq!(host_of("localhost"), "localhost");
386    }
387
388    #[tokio::test]
389    async fn remote_urls_are_never_started() {
390        // The whole gate: autostart must refuse to act for a non-loopback
391        // URL — no spawn, no health probe against third parties. (LAN/private
392        // hosts count as remote too: mermaid can't start a server there.)
393        // Checked BEFORE the test-build kill-switch, so this asserts the real
394        // production gate order.
395        for url in [
396            "https://ollama.example.com",
397            "http://192.168.1.50:11434",
398            "http://10.0.0.7:11434",
399        ] {
400            match ensure_running(url, None).await {
401                Err(AutostartError::NotLocal) => {},
402                other => panic!("{url} must be NotLocal, got {other:?}"),
403            }
404        }
405    }
406
407    #[tokio::test]
408    async fn test_builds_never_spawn_even_for_loopback() {
409        // The cfg!(test) hard-off: a loopback URL in a unit-test build stops
410        // at Disabled before the lock/probe/spawn machinery. This is what
411        // makes default-config adapters (autostart=true, localhost:11434)
412        // safe in every present and future test on machines with Ollama
413        // installed.
414        match ensure_running("http://127.0.0.1:11434", None).await {
415            Err(AutostartError::Disabled) => {},
416            other => panic!("expected Disabled in test builds, got {other:?}"),
417        }
418    }
419
420    #[tokio::test]
421    async fn notice_fires_only_when_a_spawn_is_committed() {
422        // The gate paths that return before a spawn (NotLocal, Disabled)
423        // must NOT invoke `notify` — the notice's contract is "a start is
424        // actually happening", so a remote URL or a killed switch stays
425        // silent and no false "Starting…" line ever reaches the user.
426        use std::sync::atomic::{AtomicBool, Ordering};
427        let called = AtomicBool::new(false);
428        let notify = |_: &str| called.store(true, Ordering::SeqCst);
429        let _ = ensure_running("https://ollama.example.com", Some(&notify)).await;
430        let _ = ensure_running("http://127.0.0.1:11434", Some(&notify)).await;
431        assert!(
432            !called.load(Ordering::SeqCst),
433            "notify must not fire on NotLocal/Disabled paths"
434        );
435    }
436
437    #[test]
438    fn hints_are_actionable_and_passthrough_variants_are_silent() {
439        assert!(AutostartError::NotLocal.hint().is_none());
440        assert!(AutostartError::Disabled.hint().is_none());
441        let not_installed = AutostartError::NotInstalled.hint().expect("hint");
442        assert!(not_installed.contains("https://ollama.com/download"));
443        let unhealthy = AutostartError::Unhealthy("boom".into())
444            .hint()
445            .expect("hint");
446        assert!(unhealthy.contains("boom"));
447    }
448
449    #[test]
450    fn install_candidates_exist_per_platform() {
451        // Shape check only — never spawns. Windows may legitimately return an
452        // empty list if the env vars are unset; unix lists are static.
453        let paths = known_install_paths();
454        #[cfg(not(target_os = "windows"))]
455        assert!(!paths.is_empty());
456        for p in paths {
457            assert!(p.to_string_lossy().to_lowercase().contains("ollama"));
458        }
459    }
460}