refactor: streamline runtime management surfaces
This commit is contained in:
1 parent
c91cbe51f2
commit
ae3b69b27e
18 files changed
+894
-1742
No files matched your search
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user