Skip to content

Commit 2af7ee8

Browse files
committed
AI: instant SIGKILL replaces 5s graceful shutdown
App shutdown was slow, and the Settings UI froze when stopping the server. This commit should fix this. - Replace `stop_process` (SIGTERM → 5s poll → SIGKILL) with `kill_process` (instant SIGKILL) and `kill_and_reap_in_background` (SIGKILL + bg thread `waitpid`) - llama-server is stateless — macOS reclaims all GPU/Metal/mmap resources on process death regardless of signal (llama.cpp's own test suite uses SIGKILL) - App quit and orphan cleanup use fire-and-forget `kill_process`; normal stop/switch use `kill_and_reap_in_background` to avoid zombie accumulation - Fixes: close button freezing for ~5s, UI freeze when switching from "Local LLM" to "Cloud / API" in settings
1 parent cf4f913 commit 2af7ee8

3 files changed

Lines changed: 30 additions & 35 deletions

File tree

apps/desktop/src-tauri/src/ai/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Three provider modes:
1515
| `manager.rs` | Central coordinator. Global `Mutex<Option<ManagerState>>` singleton. Most Tauri commands live here. Stores provider + OpenAI config in `ManagerState`. |
1616
| `download.rs` | HTTP streaming download with Range-based resume. Emits `ai-download-progress` events (200ms throttle). Cooperative cancellation via function parameter (`Fn() -> bool`). |
1717
| `extract.rs` | Copies bundled `llama-server` binary + dylibs from `resources/ai/` to the AI data dir. Sets Unix permissions, handles symlinks. |
18-
| `process.rs` | Spawns child process with `DYLD_LIBRARY_PATH` set. SIGTERM -> 5s wait -> SIGKILL. Port discovery via `bind(:0)`. Takes `ctx_size` param. |
18+
| `process.rs` | Spawns child process with `DYLD_LIBRARY_PATH` set. Instant SIGKILL to stop (llama-server is stateless; macOS reclaims all GPU/mmap resources). `kill_process` for fire-and-forget (quit, orphans), `kill_and_reap_in_background` for normal operation (reaps zombie in bg thread). Port discovery via `bind(:0)`. Takes `ctx_size` param. |
1919
| `client.rs` | reqwest client with `AiBackend` enum: `Local { port }` or `OpenAi { api_key, base_url, model }`. Routes requests accordingly. |
2020
| `suggestions.rs` | Builds few-shot prompt from listing cache, routes to configured backend, sanitizes response. |
2121

apps/desktop/src-tauri/src/ai/manager.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
use super::download::{cleanup_partial, download_file};
99
use super::extract::{LLAMA_SERVER_BINARY, REQUIRED_DYLIB, extract_bundled_llama_server};
1010
use super::process::{
11-
SERVER_LOG_FILENAME, find_available_port, is_process_alive, log_diagnostics, read_log_tail, spawn_llama_server,
12-
stop_process,
11+
SERVER_LOG_FILENAME, find_available_port, is_process_alive, kill_and_reap_in_background, kill_process,
12+
log_diagnostics, read_log_tail, spawn_llama_server,
1313
};
1414
use super::{AiState, AiStatus, ModelInfo, get_default_model, get_model_by_id, is_local_ai_supported};
1515
use crate::ignore_poison::IgnorePoison;
@@ -79,7 +79,7 @@ pub fn init<R: Runtime>(app: &AppHandle<R>) {
7979
{
8080
if is_process_alive(pid) {
8181
log::info!("AI manager: stopping orphaned llama-server (PID {pid}) from previous session");
82-
stop_process(pid);
82+
kill_process(pid); // Can't reap — it's a child of the old app process, not ours
8383
} else {
8484
log::debug!("AI manager: clearing dead PID {pid} from previous session");
8585
}
@@ -97,12 +97,13 @@ pub fn init<R: Runtime>(app: &AppHandle<R>) {
9797
}
9898

9999
/// Shuts down the AI server. Called on app quit.
100+
/// Fire-and-forget SIGKILL — the app is exiting so no need to reap the zombie.
100101
pub fn shutdown() {
101102
let mut manager = MANAGER.lock_ignore_poison();
102103
if let Some(ref mut m) = *manager
103104
&& let Some(pid) = m.child_pid.take()
104105
{
105-
stop_process(pid);
106+
kill_process(pid);
106107
}
107108
}
108109

@@ -199,7 +200,7 @@ pub fn cancel_ai_download() {
199200
}
200201

201202
/// Uninstalls the AI model and binary, resets state.
202-
/// Async because `stop_process` can block up to 5 seconds (SIGTERM + wait + SIGKILL).
203+
/// Async because file deletion may block briefly.
203204
#[tauri::command]
204205
pub async fn uninstall_ai() {
205206
tauri::async_runtime::spawn_blocking(uninstall_ai_sync).await.ok();
@@ -210,7 +211,7 @@ fn uninstall_ai_sync() {
210211
if let Some(ref mut m) = *manager {
211212
// Stop server if running
212213
if let Some(pid) = m.child_pid.take() {
213-
stop_process(pid);
214+
kill_and_reap_in_background(pid);
214215
}
215216

216217
// Delete files
@@ -517,7 +518,7 @@ pub fn configure_ai<R: Runtime>(
517518
if let Some(ref mut m) = *manager
518519
&& let Some(pid) = m.child_pid.take()
519520
{
520-
stop_process(pid);
521+
kill_and_reap_in_background(pid);
521522
m.state.port = None;
522523
m.state.pid = None;
523524
save_state(&m.ai_dir, &m.state);
@@ -557,7 +558,7 @@ pub fn stop_ai_server() {
557558
&& let Some(pid) = m.child_pid.take()
558559
{
559560
log::info!("AI: stopping server (PID {pid})");
560-
stop_process(pid);
561+
kill_and_reap_in_background(pid);
561562
m.state.port = None;
562563
m.state.pid = None;
563564
save_state(&m.ai_dir, &m.state);

apps/desktop/src-tauri/src/ai/process.rs

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -102,40 +102,34 @@ pub fn find_available_port() -> Option<u16> {
102102
.map(|addr| addr.port())
103103
}
104104

105-
/// Gracefully stops a process by PID, escalating to SIGKILL if needed.
106-
pub fn stop_process(pid: u32) {
105+
/// Sends SIGKILL to a process. Returns immediately (~0.5ms).
106+
///
107+
/// Use for fire-and-forget scenarios (app quit, orphan cleanup from previous sessions).
108+
/// llama-server is stateless — SIGKILL is safe. macOS reclaims all GPU/Metal/mmap resources
109+
/// on process death regardless of signal. The llama.cpp test suite itself uses SIGKILL.
110+
pub fn kill_process(pid: u32) {
107111
#[cfg(unix)]
108-
{
109-
use std::time::Duration;
110-
111-
// Send SIGTERM
112-
unsafe {
113-
libc::kill(pid as i32, libc::SIGTERM);
114-
}
115-
116-
// Wait up to 5s for graceful shutdown
117-
let start = std::time::Instant::now();
118-
while start.elapsed() < Duration::from_secs(5) {
119-
// Check if process is still alive
120-
let result = unsafe { libc::kill(pid as i32, 0) };
121-
if result != 0 {
122-
return; // Process is gone
123-
}
124-
std::thread::sleep(Duration::from_millis(100));
125-
}
126-
127-
// Force kill
128-
unsafe {
129-
libc::kill(pid as i32, libc::SIGKILL);
130-
}
112+
unsafe {
113+
libc::kill(pid as i32, libc::SIGKILL);
131114
}
132-
133115
#[cfg(not(unix))]
134116
{
135117
let _ = pid;
136118
}
137119
}
138120

121+
/// Sends SIGKILL and reaps the zombie in a background thread.
122+
///
123+
/// Use during normal operation (settings switch, explicit stop) so zombies don't
124+
/// accumulate over long-running sessions.
125+
pub fn kill_and_reap_in_background(pid: u32) {
126+
kill_process(pid);
127+
#[cfg(unix)]
128+
std::thread::spawn(move || unsafe {
129+
libc::waitpid(pid as i32, std::ptr::null_mut(), 0);
130+
});
131+
}
132+
139133
/// Returns true if the process with the given PID is still running.
140134
pub fn is_process_alive(pid: u32) -> bool {
141135
#[cfg(unix)]

0 commit comments

Comments
 (0)