@@ -171,7 +171,7 @@ def _setup_hyprland_window_rules():
171171 print (f"[DEBUG] QTWEBENGINE_CHROMIUM_FLAGS = { os .environ ['QTWEBENGINE_CHROMIUM_FLAGS' ]} " , flush = True )
172172
173173from PySide6 .QtCore import QObject , Signal , Qt
174- from PySide6 .QtWidgets import QWidget
174+ from PySide6 .QtWidgets import QApplication , QWidget
175175
176176from server import server , register_onboarding_complete_callback , register_data_reset_callback , register_window_actions , register_download_progress_callback , register_popup_visibility_callback
177177from app_controller import get_controller
@@ -272,6 +272,31 @@ def ensure_single_instance():
272272app = Pyloid (app_name = "VoiceFlow" , single_instance = True , server = server )
273273print ("[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
832872try :
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+
870917print (f"[DEBUG] About to call app.run(), onboarding_complete={ onboarding_complete } " , flush = True )
871918app .run ()
872919print ("[DEBUG] app.run() returned" , flush = True )
0 commit comments