stability: fix GTK thread safety, wakeword hang, freeze diagnostic (v2.0.0)

- daemon: GLib.timeout_add (thinking animation) was called from a background
  thread — NOT thread-safe in PyGObject/GTK3 and the prime suspect for the
  session-level freezes. Fixed via idle_add so timeout_add runs on main thread.
- daemon: add SIGQUIT handler — kill -QUIT <pid> dumps all thread stacks to
  the log so the freeze location can be identified without rebooting.
- daemon: VAD hard-timeout safety net — if LevelMeter fails to open the mic
  (device busy because wakeword pw-record holds it), dictation now auto-stops
  after 30s instead of hanging forever. Wakeword sessions also capped at 60s.
- overlay: coalesce set_level() calls the same way set_text() is coalesced,
  so mic level spikes from 3 simultaneous pw-record streams can't flood the
  GTK main loop either.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-09 15:29:45 +02:00
parent 59118364fb
commit 0d1311ba18
2 changed files with 54 additions and 3 deletions

View File

@ -7,8 +7,10 @@ routing: one hotkey records, then the spoken keyword selects the preset.
from __future__ import annotations from __future__ import annotations
import signal
import sys import sys
import threading import threading
import traceback
from typing import Callable from typing import Callable
from . import llm, quality, stt from . import llm, quality, stt
@ -151,9 +153,30 @@ class Daemon:
log(f"Using remote STT '{engine.name}' — no local model to load") log(f"Using remote STT '{engine.name}' — no local model to load")
self._prepared = True self._prepared = True
self._init_wakeword() self._init_wakeword()
self._install_freeze_diagnostic()
log("Ready.") log("Ready.")
self._emit("idle", None, "Ready") self._emit("idle", None, "Ready")
def _install_freeze_diagnostic(self) -> None:
"""Register SIGQUIT (Ctrl+\ or kill -QUIT) to dump all thread stacks.
When the system appears frozen, run:
kill -QUIT $(pgrep -f blitztext)
and the full thread dump appears in the Blitztext log window.
"""
def _dump(_sig, _frame):
lines = ["\n=== FREEZE DIAGNOSTIC — all thread stacks ==="]
for tid, frame in sys._current_frames().items():
name = next((t.name for t in threading.enumerate() if t.ident == tid), str(tid))
lines.append(f"\n-- Thread: {name} (id={tid}) --")
lines.extend(traceback.format_stack(frame))
lines.append("=== END FREEZE DIAGNOSTIC ===")
log("\n".join(lines))
try:
signal.signal(signal.SIGQUIT, _dump)
except (OSError, ValueError):
pass # not available on all platforms
@property @property
def ready(self) -> bool: def ready(self) -> bool:
return getattr(self, "_prepared", False) return getattr(self, "_prepared", False)
@ -197,7 +220,21 @@ class Daemon:
self.countdown_cb(None, silence) self.countdown_cb(None, silence)
self._vad_meter = audio.LevelMeter(self.cfg.mic, on_level=on_level, recorder=self.recorder_name) self._vad_meter = audio.LevelMeter(self.cfg.mic, on_level=on_level, recorder=self.recorder_name)
self._vad_meter.start() ok = self._vad_meter.start()
# Safety net: if the LevelMeter fails to open the mic (e.g. device busy
# because the wakeword listener already holds a pw-record stream), the
# on_level callback never fires and dictation hangs forever. Add a hard
# 30-second timeout so the session always terminates.
_MAX_WAKEWORD_SECONDS = 30
if not ok:
log("[vad] LevelMeter failed to start — scheduling 30s hard timeout")
GLib.timeout_add(_MAX_WAKEWORD_SECONDS * 1000,
lambda: self.finish_dictation(send_enter=False) or False)
else:
# Even when the meter works, cap wakeword sessions at 60s.
GLib.timeout_add(60_000,
lambda: self.is_recording and self.finish_dictation(send_enter=False) or False)
def _vad_stop(self) -> None: def _vad_stop(self) -> None:
if getattr(self, '_vad_meter', None) is not None: if getattr(self, '_vad_meter', None) is not None:
@ -533,7 +570,11 @@ class Daemon:
try: try:
from gi.repository import GLib as _GLib from gi.repository import GLib as _GLib
_GLib.timeout_add(400, _pulse_thinking) # MUST be scheduled via idle_add so timeout_add is called
# from the GTK main thread, not from _process's background
# thread. GLib.timeout_add from non-main threads is not
# thread-safe in PyGObject/GTK3 and can wedge the main loop.
_GLib.idle_add(lambda: _GLib.timeout_add(400, _pulse_thinking) and False)
except Exception: # noqa: BLE001 - headless mode, no GLib except Exception: # noqa: BLE001 - headless mode, no GLib
pass pass

View File

@ -91,6 +91,8 @@ class Overlay:
# have ONE idle_add pending — so the GTK main loop is never flooded. # have ONE idle_add pending — so the GTK main loop is never flooded.
self._pending_text: str = "" self._pending_text: str = ""
self._text_flush_queued: bool = False self._text_flush_queued: bool = False
self._pending_level: float = 0.0
self._level_flush_queued: bool = False
self._win = Gtk.Window(type=Gtk.WindowType.POPUP) self._win = Gtk.Window(type=Gtk.WindowType.POPUP)
self._win.set_app_paintable(True) self._win.set_app_paintable(True)
@ -125,7 +127,15 @@ class Overlay:
GLib.idle_add(self._show, state, window_id) GLib.idle_add(self._show, state, window_id)
def set_level(self, level: float) -> None: def set_level(self, level: float) -> None:
GLib.idle_add(self._set_level, float(level)) self._pending_level = float(level)
if not self._level_flush_queued:
self._level_flush_queued = True
GLib.idle_add(self._flush_level)
def _flush_level(self) -> bool:
self._level_flush_queued = False
self._set_level(self._pending_level)
return False
def set_text(self, text: str) -> None: def set_text(self, text: str) -> None:
self._pending_text = text or "" self._pending_text = text or ""