refactor: streamline runtime management surfaces

This commit is contained in:
Evandro Fonseca Junior committed 2026-03-27 09:16:52 -03:00
1 parent c91cbe51f2
commit ae3b69b27e
18 files changed
+894 -1742

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
use std::process::Command;
#[derive(serde::Serialize, Clone, Debug, PartialEq, Eq)]
pub struct GpuInfo {
pub name: String,
pub vram_mib: u32,
pub recommended_layers: i32,
}
fn parse_gpu_info(stdout: &str) -> Result<GpuInfo, String> {
let line = stdout.lines().next().unwrap_or("").trim();
if line.is_empty() {
return Err("No GPU found".into());
}
let parts: Vec<&str> = line.splitn(2, ',').collect();
if parts.len() < 2 {
return Err(format!("Unexpected nvidia-smi output: {line}"));
}
let name = parts[0].trim().to_string();
let vram_mib: u32 = parts[1].trim().parse().unwrap_or(0);
let usable = vram_mib.saturating_sub(1024);
let recommended_layers = (usable / 80).min(200) as i32;
Ok(GpuInfo {
name,
vram_mib,
recommended_layers,
})
}
#[tauri::command]
pub fn query_gpu() -> Result<GpuInfo, String> {
let output = Command::new("nvidia-smi")
.args([
"--query-gpu=name,memory.total",
"--format=csv,noheader,nounits",
])
.output()
.map_err(|_| "nvidia-smi not found — no NVIDIA GPU detected".to_string())?;
if !output.status.success() {
return Err("nvidia-smi failed — no NVIDIA GPU detected".into());
}
parse_gpu_info(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(test)]
mod tests {
use super::parse_gpu_info;
#[test]
fn parse_gpu_info_accepts_valid_csv_line() {
let info = parse_gpu_info("RTX 4090,24564\n").expect("parse gpu info");
assert_eq!(info.name, "RTX 4090");
assert_eq!(info.vram_mib, 24564);
assert_eq!(info.recommended_layers, 200);
}
#[test]
fn parse_gpu_info_rejects_malformed_output() {
let error = parse_gpu_info("RTX 4090").expect_err("reject malformed output");
assert!(error.contains("Unexpected nvidia-smi output"));
}
}
+4 -231
View File
@@ -1,53 +1,14 @@
use std::path::Path;
use std::process::Command;
use tauri::{AppHandle, Manager, State};
use tauri_plugin_dialog::DialogExt;
mod gpu;
mod model_files;
mod runtime;
use runtime::config::{load_runtime_config, save_runtime_config};
use runtime::process::find_localllm_binary;
use runtime::types::{RuntimeConfig, RuntimeSnapshot};
use runtime::{bootstrap_runtime, get_runtime_snapshot, save_runtime_config_command, start_runtime, stop_runtime, RuntimeState};
// ─── GPU ──────────────────────────────────────────────────────────────────────
#[derive(serde::Serialize)]
pub struct GpuInfo {
pub name: String,
pub vram_mib: u32,
pub recommended_layers: i32,
}
#[tauri::command]
fn query_gpu() -> Result<GpuInfo, String> {
let output = Command::new("nvidia-smi")
.args(["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"])
.output()
.map_err(|_| "nvidia-smi not found — no NVIDIA GPU detected".to_string())?;
if !output.status.success() {
return Err("nvidia-smi failed — no NVIDIA GPU detected".into());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.lines().next().unwrap_or("").trim().to_string();
if line.is_empty() {
return Err("No GPU found".into());
}
let parts: Vec<&str> = line.splitn(2, ',').collect();
if parts.len() < 2 {
return Err(format!("Unexpected nvidia-smi output: {line}"));
}
let name = parts[0].trim().to_string();
let vram_mib: u32 = parts[1].trim().parse().unwrap_or(0);
let usable = vram_mib.saturating_sub(1024);
let recommended_layers = (usable / 80).min(200) as i32;
Ok(GpuInfo { name, vram_mib, recommended_layers })
}
use gpu::query_gpu;
use model_files::{import_model, pick_model_files, pick_models_dir, scan_models};
// ─── Server lifecycle ─────────────────────────────────────────────────────────
@@ -86,189 +47,6 @@ fn stop_runtime_command(state: State<RuntimeState>) -> Result<RuntimeSnapshot, S
stop_runtime(state)
}
#[tauri::command]
fn start_server(
app: AppHandle,
state: State<RuntimeState>,
model_path: String,
addr: String,
gpu_layers: i32,
ctx_size: u32,
) -> Result<String, String> {
let mut config = load_runtime_config(&app).unwrap_or_default();
config.connection_mode = runtime::types::ConnectionMode::Local;
config.model_path = Some(model_path);
config.addr = addr;
config.gpu_layers = gpu_layers;
config.ctx_size = ctx_size;
save_runtime_config(&app, &config)?;
let snapshot = start_runtime(app, state)?;
Ok(format!("Local API {}", snapshot.resolved_url))
}
#[tauri::command]
fn stop_server(state: State<RuntimeState>) -> Result<String, String> {
let snapshot = stop_runtime(state)?;
Ok(format!("Local API stopped ({})", snapshot.resolved_url))
}
#[tauri::command]
fn server_status(state: State<RuntimeState>) -> String {
match get_runtime_snapshot(state) {
Ok(snapshot) if snapshot.process_running => "running".into(),
_ => "stopped".into(),
}
}
// ─── Model files ──────────────────────────────────────────────────────────────
#[derive(serde::Serialize, serde::Deserialize, Clone)]
pub struct ModelFile {
pub name: String,
pub path: String,
}
/// Scan a directory for .gguf model files.
#[tauri::command]
fn scan_models(dir: String) -> Result<Vec<ModelFile>, String> {
let path = Path::new(&dir);
if !path.exists() {
return Err(format!("Directory not found: {dir}"));
}
if !path.is_dir() {
return Err(format!("Not a directory: {dir}"));
}
let entries = std::fs::read_dir(path).map_err(|e| e.to_string())?;
let mut models = Vec::new();
for entry in entries.flatten() {
let p = entry.path();
if p.is_file() {
if let Some(ext) = p.extension() {
if ext.eq_ignore_ascii_case("gguf") {
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let full_path = p.to_string_lossy().to_string();
models.push(ModelFile { name, path: full_path });
}
}
}
}
models.sort_by(|a, b| a.name.cmp(&b.name));
Ok(models)
}
/// Open a native folder picker and scan the chosen directory for .gguf files.
/// Returns (chosen_dir, models[]).
#[derive(serde::Serialize)]
pub struct PickDirResult {
pub dir: String,
pub models: Vec<ModelFile>,
}
#[tauri::command]
async fn pick_models_dir(app: AppHandle) -> Result<PickDirResult, String> {
let folder = app
.dialog()
.file()
.set_title("Select models folder")
.blocking_pick_folder();
match folder {
Some(path) => {
let dir = path.to_string();
let models = scan_models(dir.clone())?;
Ok(PickDirResult { dir, models })
}
None => Err("cancelled".into()),
}
}
/// Open a native multi-file picker filtered to .gguf files.
/// Returns the selected ModelFile list (files in their original locations — no copy).
#[tauri::command]
async fn pick_model_files(app: AppHandle) -> Result<Vec<ModelFile>, String> {
let files = app
.dialog()
.file()
.set_title("Select model files")
.add_filter("GGUF models", &["gguf"])
.blocking_pick_files();
match files {
Some(paths) => {
let models: Vec<ModelFile> = paths
.iter()
.map(|p| {
let path_str = p.to_string();
let name = Path::new(&path_str)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
ModelFile { name, path: path_str }
})
.collect();
Ok(models)
}
None => Err("cancelled".into()),
}
}
/// Copy a .gguf file into the models directory.
#[tauri::command]
fn import_model(src_path: String, dest_dir: String) -> Result<ModelFile, String> {
let src = Path::new(&src_path);
if !src.exists() {
return Err(format!("Source file not found: {src_path}"));
}
if !src.is_file() {
return Err("Source is not a file".into());
}
let ext = src.extension().unwrap_or_default();
if !ext.eq_ignore_ascii_case("gguf") {
return Err("Only .gguf files are supported".into());
}
let dest_dir_path = Path::new(&dest_dir);
if !dest_dir_path.exists() {
std::fs::create_dir_all(dest_dir_path)
.map_err(|e| format!("Failed to create models directory: {e}"))?;
}
let file_name = src.file_name().unwrap_or_default();
let dest_path = dest_dir_path.join(file_name);
if src == dest_path {
return Ok(ModelFile {
name: file_name.to_string_lossy().to_string(),
path: dest_path.to_string_lossy().to_string(),
});
}
std::fs::copy(src, &dest_path).map_err(|e| format!("Failed to copy model: {e}"))?;
Ok(ModelFile {
name: file_name.to_string_lossy().to_string(),
path: dest_path.to_string_lossy().to_string(),
})
}
// ─── Persistent config ────────────────────────────────────────────────────────
/// All user-configurable settings, persisted to app data dir as config.json.
#[tauri::command]
fn save_config(app: AppHandle, config: RuntimeConfig) -> Result<(), String> {
save_runtime_config(&app, &config)?;
Ok(())
}
#[tauri::command]
fn load_config(app: AppHandle) -> Result<RuntimeConfig, String> {
load_runtime_config(&app)
}
/// Returns the resolved path to the localllm binary (useful for debugging).
#[tauri::command]
fn find_server_binary() -> Result<String, String> {
@@ -308,11 +86,6 @@ pub fn run() {
save_runtime_config_command_handler,
start_runtime_command,
stop_runtime_command,
start_server,
stop_server,
server_status,
save_config,
load_config,
get_app_data_dir,
find_server_binary,
])
+188
View File
@@ -0,0 +1,188 @@
use std::path::Path;
use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct ModelFile {
pub name: String,
pub path: String,
}
#[derive(serde::Serialize)]
pub struct PickDirResult {
pub dir: String,
pub models: Vec<ModelFile>,
}
fn model_file_from_path(path: &Path) -> ModelFile {
ModelFile {
name: path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
path: path.to_string_lossy().to_string(),
}
}
fn scan_model_dir(path: &Path) -> Result<Vec<ModelFile>, String> {
if !path.exists() {
return Err(format!("Directory not found: {}", path.display()));
}
if !path.is_dir() {
return Err(format!("Not a directory: {}", path.display()));
}
let entries = std::fs::read_dir(path).map_err(|e| e.to_string())?;
let mut models = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& path
.extension()
.map(|ext| ext.eq_ignore_ascii_case("gguf"))
.unwrap_or(false)
{
models.push(model_file_from_path(&path));
}
}
models.sort_by(|a, b| a.name.cmp(&b.name));
Ok(models)
}
fn import_model_file(src: &Path, dest_dir: &Path) -> Result<ModelFile, String> {
if !src.exists() {
return Err(format!("Source file not found: {}", src.display()));
}
if !src.is_file() {
return Err("Source is not a file".into());
}
if !src
.extension()
.unwrap_or_default()
.eq_ignore_ascii_case("gguf")
{
return Err("Only .gguf files are supported".into());
}
if !dest_dir.exists() {
std::fs::create_dir_all(dest_dir)
.map_err(|e| format!("Failed to create models directory: {e}"))?;
}
let file_name = src.file_name().unwrap_or_default();
let dest_path = dest_dir.join(file_name);
if src == dest_path {
return Ok(model_file_from_path(&dest_path));
}
std::fs::copy(src, &dest_path).map_err(|e| format!("Failed to copy model: {e}"))?;
Ok(model_file_from_path(&dest_path))
}
#[tauri::command]
pub fn scan_models(dir: String) -> Result<Vec<ModelFile>, String> {
scan_model_dir(Path::new(&dir))
}
#[tauri::command]
pub async fn pick_models_dir(app: AppHandle) -> Result<PickDirResult, String> {
let folder = app
.dialog()
.file()
.set_title("Select models folder")
.blocking_pick_folder();
match folder {
Some(path) => {
let dir = path.to_string();
let models = scan_model_dir(Path::new(&dir))?;
Ok(PickDirResult { dir, models })
}
None => Err("cancelled".into()),
}
}
#[tauri::command]
pub async fn pick_model_files(app: AppHandle) -> Result<Vec<ModelFile>, String> {
let files = app
.dialog()
.file()
.set_title("Select model files")
.add_filter("GGUF models", &["gguf"])
.blocking_pick_files();
match files {
Some(paths) => Ok(paths.iter().map(|path| model_file_from_path(Path::new(&path.to_string()))).collect()),
None => Err("cancelled".into()),
}
}
#[tauri::command]
pub fn import_model(src_path: String, dest_dir: String) -> Result<ModelFile, String> {
import_model_file(Path::new(&src_path), Path::new(&dest_dir))
}
#[cfg(test)]
mod tests {
use super::{import_model_file, scan_model_dir};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_path(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
std::env::temp_dir().join(format!("forgelm-{name}-{unique}"))
}
#[test]
fn scan_model_dir_filters_and_sorts_gguf_files() {
let dir = temp_path("scan-models");
std::fs::create_dir_all(&dir).expect("create temp dir");
std::fs::write(dir.join("zeta.gguf"), b"demo").expect("write zeta");
std::fs::write(dir.join("alpha.gguf"), b"demo").expect("write alpha");
std::fs::write(dir.join("notes.txt"), b"ignore").expect("write non-model file");
let models = scan_model_dir(&dir).expect("scan model dir");
let names: Vec<_> = models.into_iter().map(|model| model.name).collect();
assert_eq!(names, vec!["alpha.gguf", "zeta.gguf"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scan_model_dir_ignores_directories_named_like_models() {
let dir = temp_path("scan-model-dirs");
std::fs::create_dir_all(dir.join("folder.gguf")).expect("create fake model dir");
std::fs::write(dir.join("real.gguf"), b"demo").expect("write model file");
let models = scan_model_dir(&dir).expect("scan model dir");
let names: Vec<_> = models.into_iter().map(|model| model.name).collect();
assert_eq!(names, vec!["real.gguf"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_model_file_returns_existing_model_when_source_matches_destination() {
let dir = temp_path("import-model");
std::fs::create_dir_all(&dir).expect("create temp dir");
let model_path = dir.join("demo.gguf");
std::fs::write(&model_path, b"demo").expect("write demo model");
let model = import_model_file(&model_path, &dir).expect("import model file");
assert_eq!(model.name, "demo.gguf");
assert_eq!(PathBuf::from(model.path), model_path);
let _ = std::fs::remove_dir_all(&dir);
}
}
+6 -228
View File
@@ -1,129 +1,18 @@
pub mod config;
pub mod health;
pub mod process;
pub mod state;
pub mod types;
use std::process::Child;
use std::sync::Mutex;
use std::time::Duration;
use tauri::{AppHandle, State};
use self::config::{load_runtime_config, save_runtime_config};
use self::health::{check_health, wait_for_health};
use self::process::{find_localllm_binary, spawn_localllm};
use self::types::{
ConnectionMode, RuntimeConfig, RuntimeErrorInfo, RuntimeSnapshot, RuntimeStatus, StartPolicy,
};
pub struct RuntimeInner {
pub child: Option<Child>,
pub status: RuntimeStatus,
pub last_error: Option<RuntimeErrorInfo>,
pub config: RuntimeConfig,
}
pub struct RuntimeState(pub Mutex<RuntimeInner>);
impl RuntimeState {
pub fn new() -> Self {
Self(Mutex::new(RuntimeInner {
child: None,
status: RuntimeStatus::Stopped,
last_error: None,
config: RuntimeConfig::default(),
}))
}
}
fn runtime_error(code: &str, message: impl Into<String>) -> RuntimeErrorInfo {
RuntimeErrorInfo {
code: code.to_string(),
message: message.into(),
}
}
fn resolve_runtime_url(config: &RuntimeConfig) -> String {
match config.connection_mode {
ConnectionMode::Local => format!("http://{}", config.addr.trim()),
ConnectionMode::Remote => config.server_url.trim().trim_end_matches('/').to_string(),
}
}
fn requires_runtime_restart(current: &RuntimeConfig, next: &RuntimeConfig) -> bool {
current.connection_mode != next.connection_mode
|| current.model_path != next.model_path
|| current.addr != next.addr
|| current.gpu_layers != next.gpu_layers
|| current.ctx_size != next.ctx_size
}
fn refresh_process_state(inner: &mut RuntimeInner) {
let Some(child) = inner.child.as_mut() else {
if matches!(
inner.status,
RuntimeStatus::Starting | RuntimeStatus::Ready | RuntimeStatus::Degraded
) {
inner.status = RuntimeStatus::Stopped;
}
return;
};
match child.try_wait() {
Ok(Some(status)) => {
inner.child = None;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error(
"unexpected_exit",
format!("Local API exited unexpectedly with status {status}"),
));
}
Ok(None) => {
if inner.status == RuntimeStatus::Stopped {
inner.status = RuntimeStatus::Ready;
}
if inner.config.connection_mode == ConnectionMode::Local
&& matches!(inner.status, RuntimeStatus::Ready | RuntimeStatus::Degraded)
{
if check_health(&inner.config.addr, Duration::from_millis(400)) {
inner.status = RuntimeStatus::Ready;
if inner.last_error.as_ref().map(|error| error.code.as_str())
== Some("health_check_failed")
{
inner.last_error = None;
}
} else {
inner.status = RuntimeStatus::Degraded;
inner.last_error = Some(runtime_error(
"health_check_failed",
format!(
"Local API process is still running, but /health is no longer ready on {}.",
inner.config.addr
),
));
}
}
}
Err(error) => {
inner.child = None;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error("process_probe_failed", error.to_string()));
}
}
}
fn build_snapshot(inner: &RuntimeInner) -> RuntimeSnapshot {
RuntimeSnapshot {
status: inner.status,
ready: inner.status == RuntimeStatus::Ready,
process_running: inner.child.is_some(),
resolved_url: resolve_runtime_url(&inner.config),
binary_path: find_localllm_binary().map(|path| path.to_string_lossy().to_string()),
last_error: inner.last_error.clone(),
config: inner.config.clone(),
}
}
use self::health::wait_for_health;
use self::process::spawn_localllm;
use self::state::{build_snapshot, refresh_process_state, requires_runtime_restart, runtime_error};
use self::types::{ConnectionMode, RuntimeConfig, RuntimeSnapshot, RuntimeStatus, StartPolicy};
pub fn get_runtime_snapshot(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, String> {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
@@ -275,115 +164,4 @@ pub fn stop_runtime(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, S
Ok(build_snapshot(&inner))
}
#[cfg(test)]
mod tests {
use super::*;
fn spawn_healthy_server() -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind healthy port");
let addr = listener
.local_addr()
.expect("read healthy addr")
.to_string();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
use std::io::{Read, Write};
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer);
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 58\r\n\r\n{\"status\":\"ok\",\"model_loaded\":true,\"model_id\":\"demo\"}",
);
let _ = stream.flush();
}
});
addr
}
fn unused_local_addr() -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind unused port");
let addr = listener.local_addr().expect("read local addr").to_string();
drop(listener);
addr
}
fn spawn_sleeping_child() -> Child {
std::process::Command::new("python")
.args(["-c", "import time; time.sleep(60)"])
.spawn()
.expect("spawn sleeping child")
}
#[test]
fn runtime_revalidation_marks_unhealthy_local_process_as_degraded() {
let mut child = spawn_sleeping_child();
let addr = unused_local_addr();
let (status, error_code) = {
let mut inner = RuntimeInner {
child: Some(child),
status: RuntimeStatus::Ready,
last_error: None,
config: RuntimeConfig {
model_path: Some("D:/models/demo.gguf".to_string()),
addr,
..RuntimeConfig::default()
},
};
refresh_process_state(&mut inner);
child = inner.child.take().expect("process remains managed");
(
inner.status,
inner.last_error.as_ref().map(|error| error.code.clone()),
)
};
let _ = child.kill();
let _ = child.wait();
assert_eq!(status, RuntimeStatus::Degraded);
assert_eq!(error_code.as_deref(), Some("health_check_failed"));
}
#[test]
fn runtime_revalidation_restores_local_process_to_ready_after_health_recovers() {
let mut child = spawn_sleeping_child();
let addr = spawn_healthy_server();
let (status, error_code) = {
let mut inner = RuntimeInner {
child: Some(child),
status: RuntimeStatus::Degraded,
last_error: Some(runtime_error(
"health_check_failed",
"Local API process is still running, but /health is no longer ready.",
)),
config: RuntimeConfig {
model_path: Some("D:/models/demo.gguf".to_string()),
addr,
..RuntimeConfig::default()
},
};
refresh_process_state(&mut inner);
child = inner.child.take().expect("process remains managed");
(
inner.status,
inner.last_error.as_ref().map(|error| error.code.clone()),
)
};
let _ = child.kill();
let _ = child.wait();
assert_eq!(status, RuntimeStatus::Ready);
assert_eq!(error_code, None);
}
}
pub use state::RuntimeState;
+231
View File
@@ -0,0 +1,231 @@
use std::process::Child;
use std::sync::Mutex;
use std::time::Duration;
use super::health::check_health;
use super::process::find_localllm_binary;
use super::types::{
ConnectionMode, RuntimeConfig, RuntimeErrorInfo, RuntimeSnapshot, RuntimeStatus,
};
pub struct RuntimeInner {
pub child: Option<Child>,
pub status: RuntimeStatus,
pub last_error: Option<RuntimeErrorInfo>,
pub config: RuntimeConfig,
}
pub struct RuntimeState(pub Mutex<RuntimeInner>);
impl RuntimeState {
pub fn new() -> Self {
Self(Mutex::new(RuntimeInner {
child: None,
status: RuntimeStatus::Stopped,
last_error: None,
config: RuntimeConfig::default(),
}))
}
}
pub(crate) fn runtime_error(code: &str, message: impl Into<String>) -> RuntimeErrorInfo {
RuntimeErrorInfo {
code: code.to_string(),
message: message.into(),
}
}
fn resolve_runtime_url(config: &RuntimeConfig) -> String {
match config.connection_mode {
ConnectionMode::Local => format!("http://{}", config.addr.trim()),
ConnectionMode::Remote => config.server_url.trim().trim_end_matches('/').to_string(),
}
}
pub(crate) fn requires_runtime_restart(current: &RuntimeConfig, next: &RuntimeConfig) -> bool {
current.connection_mode != next.connection_mode
|| current.model_path != next.model_path
|| current.addr != next.addr
|| current.gpu_layers != next.gpu_layers
|| current.ctx_size != next.ctx_size
}
pub(crate) fn refresh_process_state(inner: &mut RuntimeInner) {
let Some(child) = inner.child.as_mut() else {
if matches!(
inner.status,
RuntimeStatus::Starting | RuntimeStatus::Ready | RuntimeStatus::Degraded
) {
inner.status = RuntimeStatus::Stopped;
}
return;
};
match child.try_wait() {
Ok(Some(status)) => {
inner.child = None;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error(
"unexpected_exit",
format!("Local API exited unexpectedly with status {status}"),
));
}
Ok(None) => {
if inner.status == RuntimeStatus::Stopped {
inner.status = RuntimeStatus::Ready;
}
if inner.config.connection_mode == ConnectionMode::Local
&& matches!(inner.status, RuntimeStatus::Ready | RuntimeStatus::Degraded)
{
if check_health(&inner.config.addr, Duration::from_millis(400)) {
inner.status = RuntimeStatus::Ready;
if inner.last_error.as_ref().map(|error| error.code.as_str())
== Some("health_check_failed")
{
inner.last_error = None;
}
} else {
inner.status = RuntimeStatus::Degraded;
inner.last_error = Some(runtime_error(
"health_check_failed",
format!(
"Local API process is still running, but /health is no longer ready on {}.",
inner.config.addr
),
));
}
}
}
Err(error) => {
inner.child = None;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error("process_probe_failed", error.to_string()));
}
}
}
pub(crate) fn build_snapshot(inner: &RuntimeInner) -> RuntimeSnapshot {
RuntimeSnapshot {
status: inner.status,
ready: inner.status == RuntimeStatus::Ready,
process_running: inner.child.is_some(),
resolved_url: resolve_runtime_url(&inner.config),
binary_path: find_localllm_binary().map(|path| path.to_string_lossy().to_string()),
last_error: inner.last_error.clone(),
config: inner.config.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spawn_healthy_server() -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind healthy port");
let addr = listener
.local_addr()
.expect("read healthy addr")
.to_string();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
use std::io::{Read, Write};
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer);
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 58\r\n\r\n{\"status\":\"ok\",\"model_loaded\":true,\"model_id\":\"demo\"}",
);
let _ = stream.flush();
}
});
addr
}
fn unused_local_addr() -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind unused port");
let addr = listener.local_addr().expect("read local addr").to_string();
drop(listener);
addr
}
fn spawn_sleeping_child() -> Child {
std::process::Command::new("python")
.args(["-c", "import time; time.sleep(60)"])
.spawn()
.expect("spawn sleeping child")
}
#[test]
fn runtime_revalidation_marks_unhealthy_local_process_as_degraded() {
let mut child = spawn_sleeping_child();
let addr = unused_local_addr();
let (status, error_code) = {
let mut inner = RuntimeInner {
child: Some(child),
status: RuntimeStatus::Ready,
last_error: None,
config: RuntimeConfig {
model_path: Some("D:/models/demo.gguf".to_string()),
addr,
..RuntimeConfig::default()
},
};
refresh_process_state(&mut inner);
child = inner.child.take().expect("process remains managed");
let error_code: Option<String> =
inner.last_error.as_ref().map(|error| error.code.clone());
(inner.status, error_code)
};
let _ = child.kill();
let _ = child.wait();
assert_eq!(status, RuntimeStatus::Degraded);
assert_eq!(error_code.as_deref(), Some("health_check_failed"));
}
#[test]
fn runtime_revalidation_restores_local_process_to_ready_after_health_recovers() {
let mut child = spawn_sleeping_child();
let addr = spawn_healthy_server();
let (status, error_code) = {
let mut inner = RuntimeInner {
child: Some(child),
status: RuntimeStatus::Degraded,
last_error: Some(runtime_error(
"health_check_failed",
"Local API process is still running, but /health is no longer ready.",
)),
config: RuntimeConfig {
model_path: Some("D:/models/demo.gguf".to_string()),
addr,
..RuntimeConfig::default()
},
};
refresh_process_state(&mut inner);
child = inner.child.take().expect("process remains managed");
let error_code: Option<String> =
inner.last_error.as_ref().map(|error| error.code.clone());
(inner.status, error_code)
};
let _ = child.kill();
let _ = child.wait();
assert_eq!(status, RuntimeStatus::Ready);
assert_eq!(error_code, None);
}
}