72c63aa586
Review pass on the idle-unload feature found two material concurrency bugs; both fixed here with a regression guard: 1. Unload-vs-use null deref (HIGH): _transcribe_local re-read the module global _local_model at the transcribe call site. An idle unload firing between the model load and transcribe() evaluated None.transcribe → AttributeError → user-visible 'Local transcription failed'. The window was real: the idle timer was only touched AFTER a successful transcription, so a voice note arriving exactly as the timeout expired raced the watcher directly. Fix: bind a strong local reference under the model lock and use it for the whole transcription (the watcher can null the global at any time; this in-flight call keeps its instance — the generator holds self, so no use-after-free). Also touch the idle timer at the START of transcription so a long in-flight transcribe can't be counted as idle time. The CUDA-fallback retry path gets the same treatment (locked global write, local ref use). 2. Watcher replacement race + response-path join (MEDIUM/HIGH): the old design stopped and re-started the watcher after EVERY transcription with an unlocked set/join(5)/clear/start sequence on shared globals. Two concurrent voice messages could interleave to leave TWO live watchers (one with a stale, shorter timeout — a raised unload_after_idle_seconds could still unload on the old value), and the join(timeout=5) sat on the user-visible response path (a watcher blocked on _local_model_lock during a concurrent multi-second model load stalls the reply up to 5s). Fix: single long-lived watcher under a management lock — started only when none is alive (per-transcription cost: one lock + one is_alive check), re-reads the configured timeout from config every cycle (config edits now apply within one 30s interval, without waiting for the next voice message — previously undocumented), and stands down without unloading when the timeout is set to 0 mid-idle. Tests: 17 now — idempotent start (same thread, no churn), config re-read + stand-down-when-disabled, and the race guard (unload firing mid-transcription must not fail the in-flight call). The race guard is mutation-verified: reverting the fix (re-reading the global at the call site) makes it fail with the exact NoneType error; the fixed code passes.