From 299dee1f400e4f041025e28285758999f6cfc919 Mon Sep 17 00:00:00 2001 From: Elliot Slusky <44592435+ElliotSlusky@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:24:24 -0700 Subject: [PATCH] fix(desktop): verify Rust extension before server startup (#615) Fixes #505. Make the desktop backend resilient to a missing/unbuilt openjarvis_rust extension: declare openjarvis-rust as a uv-managed desktop path dependency so 'uv sync --extra desktop' owns the PyO3 build (instead of pruning an undeclared package); add ~/.cargo/bin to the subprocess PATH and fail early with Rust / Windows Build Tools guidance when the toolchain is missing; verify 'import openjarvis_rust' before starting jarvis serve; and add a TCP bind preflight for port 8000 to catch non-HTTP listeners the /health probe can't classify. Includes the uv.lock entry for the new path dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src-tauri/src/lib.rs | 211 +++++++++++++++++++++++++++++++--- pyproject.toml | 4 + uv.lock | 7 ++ 3 files changed, 209 insertions(+), 13 deletions(-) diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index cc942084..4e9d744f 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -731,13 +731,20 @@ fn format_uv_sync_failure( let code = exit_code .map(|c| c.to_string()) .unwrap_or_else(|| "unknown".to_string()); + let tail = uv_sync_stderr_tail(stderr, 800); + let rust_hint = if looks_like_rust_extension_build_error(stderr) { + format!("\n\n{}", rust_toolchain_install_hint()) + } else { + String::new() + }; format!( "`uv sync` failed in {} (exit {}). Last output:\n\n{}\n\n\ Try opening a terminal in that directory and running \ - `uv sync --extra desktop` manually for the full output.", + `uv sync --extra desktop` manually for the full output.{}", root.display(), code, - uv_sync_stderr_tail(stderr, 800), + tail, + rust_hint, ) } @@ -786,6 +793,121 @@ fn format_uv_sync_spawn_error(root: &std::path::Path, uv_bin: &str, err: &str) - ) } +fn rust_toolchain_install_hint() -> &'static str { + "The desktop app needs the Rust toolchain to build `openjarvis_rust`. \ + Install Rust from https://rustup.rs. On Windows, also install Visual Studio \ + Build Tools with the C++ workload, then relaunch." +} + +fn looks_like_rust_extension_build_error(stderr: &str) -> bool { + let lower = stderr.to_ascii_lowercase(); + [ + "openjarvis-rust", + "openjarvis_rust", + "maturin", + "cargo", + "rustc", + "link.exe", + "visual studio", + ] + .iter() + .any(|marker| lower.contains(marker)) +} + +fn format_missing_rust_toolchain() -> String { + format!( + "Could not find Rust's `cargo` command. {}\n\n\ + If Rust is already installed, close and relaunch the desktop app so \ + PATH includes `~/.cargo/bin`.", + rust_toolchain_install_hint(), + ) +} + +fn format_extension_import_failure(root: &std::path::Path, stderr: &str) -> String { + let tail = uv_sync_stderr_tail(stderr, 4000); + format!( + "`openjarvis_rust` is still not importable after building. Last output:\n\n{}\n\n\ + Run these manually for the full build log:\n\n\ + cd {}\n\ + uv sync --extra desktop\n\ + uv run python -c \"import openjarvis_rust\"", + if tail.is_empty() { + "(no stderr output)" + } else { + &tail + }, + root.display(), + ) +} + +fn add_cargo_bin_to_path(cmd: &mut tokio::process::Command) { + let mut paths: Vec = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect()) + .unwrap_or_default(); + paths.insert( + 0, + std::path::PathBuf::from(home_dir()) + .join(".cargo") + .join("bin"), + ); + if let Ok(joined) = std::env::join_paths(paths) { + cmd.env("PATH", joined); + } +} + +async fn verify_openjarvis_rust_extension( + root: &std::path::Path, + uv_bin: &str, +) -> Result<(), String> { + let mut cmd = tokio::process::Command::new(uv_bin); + cmd.args(["run", "python", "-c", "import openjarvis_rust"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .current_dir(root); + prepare_subprocess_for_appimage(&mut cmd); + add_cargo_bin_to_path(&mut cmd); + + match cmd.output().await { + Ok(out) if out.status.success() => Ok(()), + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + Err(format_extension_import_failure(root, &stderr)) + } + Err(e) => Err(format!( + "Could not verify `openjarvis_rust`: {}. Verify uv is installed at `{}`.", + e, uv_bin + )), + } +} + +fn port_owner_hint() -> String { + if cfg!(target_os = "windows") { + format!("netstat -ano | findstr :{}", JARVIS_PORT) + } else { + format!("lsof -i :{}", JARVIS_PORT) + } +} + +fn format_port_unavailable(port: u16, reason: &str) -> String { + format!( + "Port {} is not available: {}. Stop the process using that port or \ + change the OpenJarvis port, then relaunch.\n\nTo identify it:\n {}", + port, + reason, + port_owner_hint(), + ) +} + +fn check_jarvis_port_available() -> Result<(), String> { + match std::net::TcpListener::bind(("127.0.0.1", JARVIS_PORT)) { + Ok(listener) => { + drop(listener); + Ok(()) + } + Err(err) => Err(format_port_unavailable(JARVIS_PORT, &err.to_string())), + } +} + // --------------------------------------------------------------------------- // Backend boot sequence (runs in background after app launch) // --------------------------------------------------------------------------- @@ -1171,11 +1293,6 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) { // Something else (a different web server, a stale process, // a 4xx-returning instance) is on our port. Don't kill it — // give the user actionable info instead. - let lsof_hint = if cfg!(target_os = "windows") { - format!("netstat -ano | findstr :{}", JARVIS_PORT) - } else { - format!("lsof -i :{}", JARVIS_PORT) - }; let mut s = status.lock().await; s.error = Some(format!( "Port {} is already in use by another service (it answered \ @@ -1183,7 +1300,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) { OpenJarvis port, then relaunch.\n\nTo identify it:\n {}", JARVIS_PORT, resp.status(), - lsof_hint, + port_owner_hint(), )); return; } @@ -1193,8 +1310,21 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) { } } + if let Err(err) = check_jarvis_port_available() { + let mut s = status.lock().await; + s.error = Some(err); + return; + } + let root = project_root.as_ref().unwrap(); + let cargo_bin = resolve_bin("cargo"); + if !std::path::Path::new(&cargo_bin).exists() && cargo_bin == "cargo" { + let mut s = status.lock().await; + s.error = Some(format_missing_rust_toolchain()); + return; + } + // Install dependencies automatically (handles fresh clones). // // Previously we ran `uv sync` with both stdout AND stderr piped to @@ -1226,6 +1356,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) { .current_dir(root); // Avoid LD_LIBRARY_PATH leak when running inside an AppImage (#455). prepare_subprocess_for_appimage(&mut sync_cmd); + add_cargo_bin_to_path(&mut sync_cmd); let sync_output = sync_cmd.output().await; match sync_output { Ok(out) if !out.status.success() => { @@ -1242,6 +1373,16 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) { Ok(_) => {} // success — fall through } + { + let mut s = status.lock().await; + s.detail = "Verifying Rust extension (openjarvis_rust)...".into(); + } + if let Err(err) = verify_openjarvis_rust_extension(root, &uv_bin).await { + let mut s = status.lock().await; + s.error = Some(err); + return; + } + { let mut s = status.lock().await; s.detail = format!("Starting API server from {}...", root.display()); @@ -2720,11 +2861,12 @@ pub fn run() { #[cfg(test)] mod tests { use super::{ - boot_plan, default_local_model, format_uv_sync_failure, format_uv_sync_spawn_error, - matching_installed_model, model_names_match, normalize_host, parse_inference_config, - parse_ollama_model_names, preferred_installed_model, should_persist_resolved_model, - startup_installed_model, - upsert_engine_host, uv_sync_stderr_tail, InferenceConfig, SourceKind, + boot_plan, default_local_model, format_extension_import_failure, + format_missing_rust_toolchain, format_port_unavailable, format_uv_sync_failure, + format_uv_sync_spawn_error, matching_installed_model, model_names_match, normalize_host, + parse_inference_config, parse_ollama_model_names, preferred_installed_model, + should_persist_resolved_model, startup_installed_model, upsert_engine_host, + uv_sync_stderr_tail, InferenceConfig, SourceKind, }; use std::path::Path; @@ -2791,6 +2933,49 @@ mod tests { assert!(msg.contains("No such file or directory")); } + #[test] + fn missing_rust_toolchain_message_names_cargo_and_installer() { + let msg = format_missing_rust_toolchain(); + assert!(msg.contains("cargo")); + assert!(msg.contains("https://rustup.rs")); + assert!(msg.contains("openjarvis_rust")); + assert!(msg.contains("Visual Studio Build Tools")); + } + + #[test] + fn uv_sync_rust_failure_mentions_toolchain() { + let msg = format_uv_sync_failure( + Path::new("C:\\Users\\me\\OpenJarvis"), + Some(1), + "maturin failed: linker `link.exe` not found while building openjarvis-rust", + ); + assert!(msg.contains("exit 1")); + assert!(msg.contains("link.exe")); + assert!(msg.contains("https://rustup.rs")); + assert!(msg.contains("Visual Studio Build Tools")); + } + + #[test] + fn extension_import_failure_names_verification_command() { + let msg = format_extension_import_failure( + Path::new("C:\\Users\\me\\OpenJarvis"), + "ModuleNotFoundError: No module named 'openjarvis_rust'", + ); + assert!(msg.contains("openjarvis_rust")); + assert!(msg.contains("uv sync --extra desktop")); + assert!(msg.contains("uv run python -c \"import openjarvis_rust\"")); + assert!(msg.contains("ModuleNotFoundError")); + } + + #[test] + fn port_unavailable_message_names_port_and_owner_hint() { + let msg = format_port_unavailable(8000, "address already in use"); + assert!(msg.contains("Port 8000 is not available")); + assert!(msg.contains("address already in use")); + assert!(msg.contains("To identify it")); + assert!(msg.contains("8000")); + } + #[test] fn default_local_model_picks_second_largest_that_fits() { // QWEN35_MODELS min_ram ladder: 4,6,8,12,24,32,96 GB diff --git a/pyproject.toml b/pyproject.toml index 3d3c94a8..5b9f669c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,7 @@ desktop = [ "pydantic>=2.0", "python-multipart>=0.0.9", "faster-whisper>=1.0", + "openjarvis-rust", ] openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"] gpu-metrics = ["pynvml>=12.0"] @@ -186,6 +187,9 @@ git_describe_command = [ # Such builds can inject the real version via SETUPTOOLS_SCM_PRETEND_VERSION. fallback_version = "0.0.0+unknown" +[tool.uv.sources] +openjarvis-rust = { path = "rust/crates/openjarvis-python" } + [tool.hatch.build.targets.wheel] packages = ["src/openjarvis"] diff --git a/uv.lock b/uv.lock index 2583fa65..9936fc77 100644 --- a/uv.lock +++ b/uv.lock @@ -4206,6 +4206,7 @@ dashboard = [ desktop = [ { name = "fastapi" }, { name = "faster-whisper" }, + { name = "openjarvis-rust" }, { name = "pydantic" }, { name = "python-multipart" }, { name = "uvicorn" }, @@ -4390,6 +4391,7 @@ requires-dist = [ { name = "openai", marker = "extra == 'inference-cloud'", specifier = ">=1.30" }, { name = "openai", marker = "extra == 'media'", specifier = ">=1.30" }, { name = "openhands-sdk", marker = "python_full_version >= '3.12' and extra == 'openhands'", specifier = ">=1.0" }, + { name = "openjarvis-rust", marker = "extra == 'desktop'", directory = "rust/crates/openjarvis-python" }, { name = "pdfplumber", marker = "extra == 'memory-pdf'", specifier = ">=0.10" }, { name = "pdfplumber", marker = "extra == 'pdf'", specifier = ">=0.10" }, { name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40" }, @@ -4445,6 +4447,11 @@ provides-extras = ["browser", "channel-discord", "channel-gmail", "channel-line" [package.metadata.requires-dev] dev = [{ name = "maturin", specifier = ">=1.12.6" }] +[[package]] +name = "openjarvis-rust" +version = "0.1.0" +source = { directory = "rust/crates/openjarvis-python" } + [[package]] name = "opentelemetry-api" version = "1.39.1"