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 <cj@cjpais.com>
This commit is contained in:
Viren Mohindra 2026-03-17 00:51:15 -04:00 committed by GitHub
parent 789a4393e8
commit d1da935479
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 40 additions and 21 deletions

View file

@ -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);

View file

@ -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(),