From 0d1311ba187cc417b94775e1a1677be7f97cfaf3 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Tue, 9 Jun 2026 15:29:45 +0200 Subject: [PATCH] stability: fix GTK thread safety, wakeword hang, freeze diagnostic (v2.0.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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 --- linux/blitztext/daemon.py | 45 ++++++++++++++++++++++++++++++++++++-- linux/blitztext/overlay.py | 12 +++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index df1a4fa..a8941ae 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -7,8 +7,10 @@ routing: one hotkey records, then the spoken keyword selects the preset. from __future__ import annotations +import signal import sys import threading +import traceback from typing import Callable from . import llm, quality, stt @@ -151,9 +153,30 @@ class Daemon: log(f"Using remote STT '{engine.name}' — no local model to load") self._prepared = True self._init_wakeword() + self._install_freeze_diagnostic() log("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 def ready(self) -> bool: return getattr(self, "_prepared", False) @@ -197,7 +220,21 @@ class Daemon: self.countdown_cb(None, silence) 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: if getattr(self, '_vad_meter', None) is not None: @@ -533,7 +570,11 @@ class Daemon: try: 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 pass diff --git a/linux/blitztext/overlay.py b/linux/blitztext/overlay.py index a6b5b5f..50e1a05 100644 --- a/linux/blitztext/overlay.py +++ b/linux/blitztext/overlay.py @@ -91,6 +91,8 @@ class Overlay: # have ONE idle_add pending — so the GTK main loop is never flooded. self._pending_text: str = "" 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.set_app_paintable(True) @@ -125,7 +127,15 @@ class Overlay: GLib.idle_add(self._show, state, window_id) 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: self._pending_text = text or ""