From d1da935479b57529eecdc04345414fe055d42d0a Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Tue, 17 Mar 2026 00:51:15 -0400 Subject: [PATCH] fix: auto-unload model after idle timeout to reduce memory (#1051) - Default model_unload_timeout from Never to Min5 - Fix Drop impl: use take() on watcher handle so clones from initiate_model_load() don't kill the watcher thread - Reset last_activity on model load to prevent immediate unload - Upgrade watcher logging from debug to info level - Remove duplicate "unloaded" event (unload_model already emits it) Co-authored-by: CJ Pais --- src-tauri/src/managers/transcription.rs | 57 ++++++++++++++++--------- src-tauri/src/settings.rs | 4 +- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index 7f6e7ac..73e4e5b 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -97,6 +97,7 @@ impl TranscriptionManager { let manager_cloned = manager.clone(); let shutdown_signal = manager.shutdown_signal.clone(); let handle = thread::spawn(move || { + debug!("Idle watcher thread started"); while !shutdown_signal.load(Ordering::Relaxed) { thread::sleep(Duration::from_secs(10)); // Check every 10 seconds @@ -114,28 +115,29 @@ impl TranscriptionManager { .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_millis() as u64; + let idle_ms = now_ms.saturating_sub(last); + let limit_ms = limit_seconds * 1000; - if now_ms.saturating_sub(last) > limit_seconds * 1000 { + if idle_ms > limit_ms { // idle -> unload if manager_cloned.is_model_loaded() { let unload_start = std::time::Instant::now(); - debug!("Starting to unload model due to inactivity"); - - if let Ok(()) = manager_cloned.unload_model() { - let _ = app_handle_cloned.emit( - "model-state-changed", - ModelStateEvent { - event_type: "unloaded".to_string(), - model_id: None, - model_name: None, - error: None, - }, - ); - let unload_duration = unload_start.elapsed(); - debug!( - "Model unloaded due to inactivity (took {}ms)", - unload_duration.as_millis() - ); + info!( + "Model idle for {}s (limit: {}s), unloading", + idle_ms / 1000, + limit_seconds + ); + match manager_cloned.unload_model() { + Ok(()) => { + let unload_duration = unload_start.elapsed(); + info!( + "Model unloaded due to inactivity (took {}ms)", + unload_duration.as_millis() + ); + } + Err(e) => { + error!("Failed to unload idle model: {}", e); + } } } } @@ -399,6 +401,15 @@ impl TranscriptionManager { *current_model = Some(model_id.to_string()); } + // Reset idle timer so the watcher doesn't immediately unload a just-loaded model + self.last_activity.store( + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_millis() as u64, + Ordering::Relaxed, + ); + // Emit loading completed event let _ = self.app_handle.emit( "model-state-changed", @@ -771,7 +782,15 @@ pub fn get_available_accelerators() -> AvailableAccelerators { impl Drop for TranscriptionManager { fn drop(&mut self) { - debug!("Shutting down TranscriptionManager"); + // Skip shutdown unless this is the very last clone. TranscriptionManager + // is cloned by initiate_model_load() and the watcher thread — those + // clones dropping must not kill the watcher. The watcher thread holds + // its own clone, so engine's strong_count is always >= 2 while the + // watcher is alive. When it reaches 1, only this instance remains + // and we can safely shut down. + if Arc::strong_count(&self.engine) > 1 { + return; + } // Signal the watcher thread to shutdown self.shutdown_signal.store(true, Ordering::Relaxed); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 9532168..a713dae 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -180,7 +180,7 @@ impl Default for KeyboardImplementation { impl Default for ModelUnloadTimeout { fn default() -> Self { - ModelUnloadTimeout::Never + ModelUnloadTimeout::Min5 } } @@ -735,7 +735,7 @@ pub fn get_default_settings() -> AppSettings { debug_mode: false, log_level: default_log_level(), custom_words: Vec::new(), - model_unload_timeout: ModelUnloadTimeout::Never, + model_unload_timeout: ModelUnloadTimeout::default(), word_correction_threshold: default_word_correction_threshold(), history_limit: default_history_limit(), recording_retention_period: default_recording_retention_period(),