use std::path::PathBuf;
use std::process::Stdio;
use std::time::{Duration, Instant};
use crate::utils::classify_host;
const COOLDOWN: Duration = Duration::from_secs(15);
const STARTUP_DEADLINE: Duration = Duration::from_secs(15);
const POLL_INTERVAL: Duration = Duration::from_millis(300);
#[derive(Debug, Clone)]
pub enum AutostartError {
NotLocal,
Disabled,
NotInstalled,
Unhealthy(String),
}
impl AutostartError {
pub fn hint(&self) -> Option<String> {
match self {
AutostartError::NotLocal | AutostartError::Disabled => None,
AutostartError::NotInstalled => Some(
"Ollama doesn't appear to be installed (not on PATH or in the default \
install locations) — install it from https://ollama.com/download"
.to_string(),
),
AutostartError::Unhealthy(detail) => Some(format!("auto-start failed: {detail}")),
}
}
}
struct AttemptState {
last_failure: Option<(Instant, AutostartError)>,
}
static STATE: std::sync::LazyLock<tokio::sync::Mutex<AttemptState>> =
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(AttemptState { last_failure: None }));
fn autostart_disabled() -> bool {
cfg!(test) || std::env::var_os("MERMAID_OLLAMA_AUTOSTART").is_some_and(|v| v == "0")
}
pub const STARTING_NOTICE: &str =
"Starting the local Ollama server (it stays running after mermaid exits)…";
pub async fn ensure_running(
base_url: &str,
notify: Option<&(dyn Fn(&str) + Sync)>,
) -> Result<(), AutostartError> {
let authority = authority_of(base_url).to_string();
if !classify_host(host_of(&authority)).is_loopback() {
return Err(AutostartError::NotLocal);
}
if autostart_disabled() {
return Err(AutostartError::Disabled);
}
let Ok(client) = reqwest::Client::builder()
.timeout(Duration::from_secs(1))
.build()
else {
return Err(AutostartError::Unhealthy(
"could not build a health-probe HTTP client".to_string(),
));
};
let mut state = STATE.lock().await;
if healthy(&client, base_url).await {
state.last_failure = None;
return Ok(());
}
if let Some((at, err)) = &state.last_failure
&& at.elapsed() < COOLDOWN
{
return Err(err.clone());
}
let outcome = start_and_wait(&mut state, &client, base_url, &authority, notify).await;
state.last_failure = match &outcome {
Ok(()) => None,
Err(e) => Some((Instant::now(), e.clone())),
};
outcome
}
async fn start_and_wait(
state: &mut AttemptState,
client: &reqwest::Client,
base_url: &str,
authority: &str,
notify: Option<&(dyn Fn(&str) + Sync)>,
) -> Result<(), AutostartError> {
let Some(binary) = find_binary() else {
return Err(AutostartError::NotInstalled);
};
if let Some(notify) = notify {
notify(STARTING_NOTICE);
}
tracing::info!(
binary = %binary.display(),
authority,
"ollama is not running — starting `ollama serve`"
);
let mut child = spawn_serve(&binary, authority).map_err(|e| {
AutostartError::Unhealthy(format!(
"could not launch `{} serve`: {e}",
binary.display()
))
})?;
state.last_failure = Some((
Instant::now(),
AutostartError::Unhealthy(
"`ollama serve` was started moments ago and may still be coming up — retry shortly"
.to_string(),
),
));
let deadline = Instant::now() + STARTUP_DEADLINE;
loop {
if healthy(client, base_url).await {
tracing::info!(%base_url, "ollama serve is up");
return Ok(());
}
if let Ok(Some(status)) = child.try_wait() {
return Err(AutostartError::Unhealthy(format!(
"`ollama serve` exited immediately ({status}) — is another server \
holding the port, or is OLLAMA_HOST misconfigured?"
)));
}
if Instant::now() >= deadline {
return Err(AutostartError::Unhealthy(format!(
"started `ollama serve` but {base_url} was not reachable within {}s",
STARTUP_DEADLINE.as_secs()
)));
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
async fn healthy(client: &reqwest::Client, base_url: &str) -> bool {
let url = format!("{}/api/version", base_url);
matches!(client.get(&url).send().await, Ok(r) if r.status().is_success())
}
fn spawn_serve(binary: &std::path::Path, authority: &str) -> std::io::Result<std::process::Child> {
let mut cmd = std::process::Command::new(binary);
cmd.arg("serve")
.env("OLLAMA_HOST", authority)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::utils::DETACHED_PROCESS | crate::utils::CREATE_NEW_PROCESS_GROUP);
}
cmd.spawn()
}
pub(crate) fn find_binary() -> Option<PathBuf> {
if let Ok(path) = which::which("ollama") {
return Some(path);
}
known_install_paths().into_iter().find(|p| p.is_file())
}
#[cfg(target_os = "windows")]
fn known_install_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(base) = std::env::var_os("LOCALAPPDATA") {
paths.push(
PathBuf::from(base)
.join("Programs")
.join("Ollama")
.join("ollama.exe"),
);
}
if let Some(base) = std::env::var_os("ProgramFiles") {
paths.push(PathBuf::from(base).join("Ollama").join("ollama.exe"));
}
paths
}
#[cfg(target_os = "macos")]
fn known_install_paths() -> Vec<PathBuf> {
vec![
PathBuf::from("/opt/homebrew/bin/ollama"),
PathBuf::from("/usr/local/bin/ollama"),
PathBuf::from("/Applications/Ollama.app/Contents/Resources/ollama"),
]
}
#[cfg(all(unix, not(target_os = "macos")))]
fn known_install_paths() -> Vec<PathBuf> {
vec![
PathBuf::from("/usr/local/bin/ollama"),
PathBuf::from("/usr/bin/ollama"),
]
}
fn authority_of(base_url: &str) -> &str {
let rest = base_url
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(base_url);
rest.split(['/', '?', '#']).next().unwrap_or(rest)
}
fn host_of(authority: &str) -> &str {
if let Some(end) = authority.rfind(']') {
return &authority[..=end];
}
authority.split(':').next().unwrap_or(authority)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn authority_strips_scheme_and_path() {
assert_eq!(authority_of("http://localhost:11434"), "localhost:11434");
assert_eq!(authority_of("http://127.0.0.1:11434/v1"), "127.0.0.1:11434");
assert_eq!(
authority_of("https://ollama.example.com/api?x=1"),
"ollama.example.com"
);
assert_eq!(authority_of("localhost:11434"), "localhost:11434");
}
#[test]
fn host_extracts_from_authority() {
assert_eq!(host_of("localhost:11434"), "localhost");
assert_eq!(host_of("127.0.0.1:8080"), "127.0.0.1");
assert_eq!(host_of("[::1]:11434"), "[::1]");
assert_eq!(host_of("localhost"), "localhost");
}
#[tokio::test]
async fn remote_urls_are_never_started() {
for url in [
"https://ollama.example.com",
"http://192.168.1.50:11434",
"http://10.0.0.7:11434",
] {
match ensure_running(url, None).await {
Err(AutostartError::NotLocal) => {},
other => panic!("{url} must be NotLocal, got {other:?}"),
}
}
}
#[tokio::test]
async fn test_builds_never_spawn_even_for_loopback() {
match ensure_running("http://127.0.0.1:11434", None).await {
Err(AutostartError::Disabled) => {},
other => panic!("expected Disabled in test builds, got {other:?}"),
}
}
#[tokio::test]
async fn notice_fires_only_when_a_spawn_is_committed() {
use std::sync::atomic::{AtomicBool, Ordering};
let called = AtomicBool::new(false);
let notify = |_: &str| called.store(true, Ordering::SeqCst);
let _ = ensure_running("https://ollama.example.com", Some(¬ify)).await;
let _ = ensure_running("http://127.0.0.1:11434", Some(¬ify)).await;
assert!(
!called.load(Ordering::SeqCst),
"notify must not fire on NotLocal/Disabled paths"
);
}
#[test]
fn hints_are_actionable_and_passthrough_variants_are_silent() {
assert!(AutostartError::NotLocal.hint().is_none());
assert!(AutostartError::Disabled.hint().is_none());
let not_installed = AutostartError::NotInstalled.hint().expect("hint");
assert!(not_installed.contains("https://ollama.com/download"));
let unhealthy = AutostartError::Unhealthy("boom".into())
.hint()
.expect("hint");
assert!(unhealthy.contains("boom"));
}
#[test]
fn install_candidates_exist_per_platform() {
let paths = known_install_paths();
#[cfg(not(target_os = "windows"))]
assert!(!paths.is_empty());
for p in paths {
assert!(p.to_string_lossy().to_lowercase().contains("ollama"));
}
}
}