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:
parent
59118364fb
commit
0d1311ba18
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 ""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user