Skip to content

Commit c2b663d

Browse files
authored
Merge pull request #24 from infiniV/release/v1.6.0-rc2
release: v1.6.0-rc2 hotfix bundle
2 parents 22f5fdc + 0023b97 commit c2b663d

10 files changed

Lines changed: 113 additions & 8 deletions

File tree

installer/voiceflow.iss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
; Creates a Windows installer from the PyInstaller --onedir output
33

44
#define MyAppName "VoiceFlow"
5-
#define MyAppVersion "1.6.0-rc1"
5+
#define MyAppVersion "1.6.0-rc2"
66
#define MyAppPublisher "infiniV"
77
#define MyAppURL "https://get-voice-flow.vercel.app/"
88
#define MyAppSupportURL "https://github.com/infiniV/VoiceFlow/issues"

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "voiceflow",
33
"private": true,
4-
"version": "1.6.0-rc1",
4+
"version": "1.6.0-rc2",
55
"type": "module",
66
"scripts": {
77
"dev": "npm-run-all --parallel vite pyloid",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "VoiceFlow"
3-
version = "1.6.0rc1"
3+
version = "1.6.0rc2"
44
readme = "README.md"
55
description = "Offline voice-to-text dictation for Windows and Linux, using Whisper."
66
keywords = ["voice-to-text", "dictation", "whisper", "speech-to-text", "offline", "linux", "wayland", "transcription", "stt", "faster-whisper", "privacy"]

src-pyloid/app_controller.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ def __init__(self):
5656
self._model_loaded = False
5757
self._model_loading = False
5858

59+
# Shutdown is wired from both QApplication.aboutToQuit and the
60+
# post-app.run() path in main.py; this guard makes a second call a
61+
# no-op instead of double-stopping the hotkey listener.
62+
self._shutdown_done = False
63+
5964
# Popup enabled state (disabled during onboarding)
6065
self._popup_enabled = True
6166

@@ -144,8 +149,24 @@ def load_model():
144149
self.db.clear_old_history(settings.retention)
145150

146151
def shutdown(self):
147-
"""Clean shutdown."""
152+
"""Clean shutdown. Idempotent — wired from both QApplication.aboutToQuit
153+
and main.py's post-app.run() path, so it can fire twice on a normal exit."""
154+
if self._shutdown_done:
155+
return
156+
self._shutdown_done = True
148157
self.hotkey_service.stop()
158+
# Stop any active meeting recording before tearing down. parec /
159+
# sounddevice hold PipeWire / PortAudio handles that, if leaked, can
160+
# corrupt the global audio routing graph (observed: Teams loses
161+
# inbound audio after a VoiceFlow wedge + window close). Failure is
162+
# tolerated — recover_unfinished() on the next startup will pick up
163+
# any partial recording.
164+
try:
165+
if self.meetings.recorder.get_state()["state"] != "idle":
166+
info("Shutdown: stopping active meeting recording")
167+
self.meetings.stop()
168+
except Exception as exc:
169+
warning(f"Shutdown: failed to stop active meeting: {exc}")
149170
self.transcription_service.unload_model()
150171

151172
def _handle_hotkey_activate(self):

src-pyloid/main.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ def _setup_hyprland_window_rules():
171171
print(f"[DEBUG] QTWEBENGINE_CHROMIUM_FLAGS = {os.environ['QTWEBENGINE_CHROMIUM_FLAGS']}", flush=True)
172172

173173
from PySide6.QtCore import QObject, Signal, Qt
174-
from PySide6.QtWidgets import QWidget
174+
from PySide6.QtWidgets import QApplication, QWidget
175175

176176
from server import server, register_onboarding_complete_callback, register_data_reset_callback, register_window_actions, register_download_progress_callback, register_popup_visibility_callback
177177
from app_controller import get_controller
@@ -272,6 +272,31 @@ def ensure_single_instance():
272272
app = Pyloid(app_name="VoiceFlow", single_instance=True, server=server)
273273
print("[DEBUG] Pyloid app created", flush=True)
274274

275+
# Tray-resident daemon: closing the dashboard window must NOT quit the app.
276+
# Qt's default is True, which combined with Qt WebEngine's built-in Ctrl+W
277+
# turns "close window" into "kill the daemon" — global hotkey dies, model
278+
# reloads on respawn. The tray "Quit" item remains the only true exit.
279+
QApplication.instance().setQuitOnLastWindowClosed(False)
280+
281+
# Patch pyloid.Pyloid.get_window_by_id to bypass its cross-thread Qt
282+
# roundtrip. pyloid-rpc validates the window_id on EVERY RPC request
283+
# (rpc.py:518) by calling self.pyloid.get_window_by_id(request_id), which
284+
# uses execute_command() — that emits a Qt signal to the main thread and
285+
# runs a nested QEventLoop.exec() in the asyncio RPC thread, with NO
286+
# timeout, waiting for the Qt main thread to reply. If the Qt main thread
287+
# is even briefly busy (WebEngine IPC, queued window.invoke calls during
288+
# an active meeting recording, etc.), the asyncio thread wedges forever
289+
# and every subsequent RPC request stacks up in aiohttp's accept queue
290+
# (observed in the field: LISTEN 4 128 with the Stop button dead while
291+
# the writer thread + WebChannel kept ticking). The Qt-side handler is
292+
# literally just `self.app.windows_dict.get(window_id)` (pyloid.py:2117,
293+
# 597), so we can do it directly from the asyncio thread — dict.get() is
294+
# atomic under the GIL.
295+
import types as _types
296+
def _fast_get_window_by_id(self, window_id):
297+
return self.app.windows_dict.get(window_id)
298+
app.get_window_by_id = _types.MethodType(_fast_get_window_by_id, app)
299+
275300
# Install the voiceflow:// handler on the default profile. The scheme itself
276301
# was registered above (before QApplication). The handler must outlive every
277302
# request, so we hold a module-level reference — Qt holds a non-owning ref.
@@ -823,11 +848,26 @@ def close_main_window():
823848
# Dev: Standard Frame
824849
window = app.create_window(title="VoiceFlow", dev_tools=False, frame=True, transparent=False)
825850
# try:
826-
# window._window.web_view.page().setBackgroundColor(QColor(0, 0, 0, 0))
851+
# window._window.web_view.page().setBackgroundColor(QColor(0, 0, 0, 0))
827852
# except Exception as e:
828853
# error(f"Failed to set transparent background: {e}")
829854
window.load_url("http://localhost:5173")
830855

856+
# Qt-level close (native title-bar X, Ctrl+W from Qt WebEngine) routes through
857+
# Pyloid's BrowserWindow.closeEvent, which removes the window from
858+
# app.windows_dict and unconditionally calls app.quit() once the dict is empty
859+
# (.venv/.../pyloid/browser_window.py:1115). That bypasses
860+
# setQuitOnLastWindowClosed(False) — confirmed in dev: with the popup hidden
861+
# by preference, Ctrl+W on the dashboard fires app.quit() and the daemon dies.
862+
# Override the QMainWindow's closeEvent so any Qt-level close just hides the
863+
# window — matching the frontend's close_main_window RPC path. Tray "Quit"
864+
# still calls app.quit() explicitly, so explicit exits are unaffected.
865+
def _hide_main_window_on_close(event):
866+
event.ignore()
867+
if window:
868+
window.hide()
869+
window._window._window.closeEvent = _hide_main_window_on_close
870+
831871
# Enforce Minimum Size Globally based on Screen Size
832872
try:
833873
# Use cached screen info or default
@@ -867,6 +907,13 @@ def close_main_window():
867907
log.info("Showing onboarding window")
868908
# Don't initialize popup during onboarding
869909

910+
# Tear down services BEFORE Qt destroys QApplication. CTranslate2's CUDA
911+
# worker threads need to be joined while libcuda is still loaded — running
912+
# this in aboutToQuit (Qt event loop still alive) avoids the SIGABRT race
913+
# against libcuda's own atexit handler. The post-app.run() call below stays
914+
# as a fallback for non-Qt exit paths; controller.shutdown() is idempotent.
915+
QApplication.instance().aboutToQuit.connect(controller.shutdown)
916+
870917
print(f"[DEBUG] About to call app.run(), onboarding_complete={onboarding_complete}", flush=True)
871918
app.run()
872919
print("[DEBUG] app.run() returned", flush=True)

src-pyloid/services/database.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,16 @@ def __init__(self, db_path: Optional[Path] = None):
2020
self._init_db()
2121

2222
def _get_connection(self) -> sqlite3.Connection:
23-
conn = sqlite3.connect(self.db_path)
23+
# timeout=5.0 + busy_timeout=5000 means SQLite returns SQLITE_BUSY
24+
# after 5s of lock contention instead of blocking the asyncio RPC
25+
# thread indefinitely. RPC handlers like get_history / get_stats are
26+
# `async def` but call sync sqlite synchronously — without a timeout,
27+
# a long-held write lock from another connection could wedge the
28+
# whole HTTP RPC server.
29+
conn = sqlite3.connect(self.db_path, timeout=5.0)
2430
conn.row_factory = sqlite3.Row
2531
conn.execute("PRAGMA foreign_keys = ON")
32+
conn.execute("PRAGMA busy_timeout = 5000")
2633
return conn
2734

2835
def _init_db(self):

src-pyloid/services/recording/audio_source.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,17 @@ def start(self, on_frames: FrameCallback) -> None:
270270
)
271271
self._callback = on_frames
272272
self._stopped = False
273+
# NOTE: do not pass preexec_fn here. We previously used it to set
274+
# PR_SET_PDEATHSIG so parec would die with the parent — but
275+
# preexec_fn is documented as unsafe in a multithreaded process
276+
# (subprocess docs: the child can deadlock before exec if it
277+
# inherits a held lock from another thread). VoiceFlow has many
278+
# threads (writer, hotkey, asyncio RPC, transcribe queue, etc.)
279+
# and that deadlock fired in the field on Bluetooth source
280+
# selection — wedged the entire asyncio loop and corrupted
281+
# PipeWire routing. Rely instead on controller.shutdown() (wired
282+
# to QApplication.aboutToQuit) to kill parec on every normal exit
283+
# path. Orphan parec on SIGKILL is the accepted trade-off.
273284
self._proc = subprocess.Popen(
274285
[
275286
"parec",

src-pyloid/tests/test_app_controller.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def controller(temp_db):
3333
ctrl._on_transcription_complete = None
3434
ctrl._on_amplitude = None
3535
ctrl._on_error = None
36+
ctrl._shutdown_done = False
3637

3738
yield ctrl
3839

@@ -145,3 +146,12 @@ def test_shutdown_stops_hotkey_service(self, controller):
145146
controller.shutdown()
146147

147148
assert controller.hotkey_service.is_running() == False
149+
150+
def test_shutdown_is_idempotent(self, controller):
151+
"""shutdown can be called twice — wired from both aboutToQuit and the
152+
post-app.run() path in main.py."""
153+
controller.hotkey_service.start()
154+
controller.shutdown()
155+
# Second call must not raise; service must still be stopped.
156+
controller.shutdown()
157+
assert controller.hotkey_service.is_running() == False

src/components/HomePage.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,23 @@ export function HomePage() {
5454
}, []);
5555

5656
// Sync recording state with backend (covers hotkey-driven starts).
57+
// In-flight guard prevents request stacking when the RPC server slows
58+
// (observed: during long meeting recordings the asyncio RPC thread became
59+
// sluggish; without this guard, getRecordingState calls accumulated and
60+
// saturated aiohttp's accept queue, wedging the whole HTTP server).
5761
useEffect(() => {
5862
let cancelled = false;
63+
let inFlight = false;
5964
const tick = async () => {
65+
if (inFlight) return;
66+
inFlight = true;
6067
try {
6168
const state = await api.getRecordingState();
6269
if (!cancelled) setIsRecording(state.recording);
6370
} catch {
6471
// backend may not be ready yet
72+
} finally {
73+
inFlight = false;
6574
}
6675
};
6776
tick();

src/lib/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
import { Lock, Gauge, Wand2 } from "lucide-react";
33

4-
export const APP_VERSION = "1.6.0-rc1";
4+
export const APP_VERSION = "1.6.0-rc2";
55

66
export const THEME_OPTIONS = [
77
{ val: 'light', label: 'Light' },

0 commit comments

Comments
 (0)