Attempt to fix bug where speech could not be interrupted during some race conditions.

This commit is contained in:
Storm Dragon
2026-07-06 12:15:45 -04:00
parent dfef847fb5
commit 3926ed0044
10 changed files with 534 additions and 146 deletions
+27 -5
View File
@@ -196,6 +196,8 @@ class Parser(argparse.ArgumentParser):
help=messages.CLI_DEBUG_FILE, metavar=messages.CLI_DEBUG_FILE_NAME) help=messages.CLI_DEBUG_FILE, metavar=messages.CLI_DEBUG_FILE_NAME)
self.add_argument( self.add_argument(
"--debug", action="store_true", help=messages.CLI_ENABLE_DEBUG) "--debug", action="store_true", help=messages.CLI_ENABLE_DEBUG)
self.add_argument(
"--debug-startup", action="store_true", help=messages.CLI_ENABLE_STARTUP_DEBUG)
self._optionals.title = messages.CLI_OPTIONAL_ARGUMENTS self._optionals.title = messages.CLI_OPTIONAL_ARGUMENTS
@@ -323,6 +325,8 @@ def cleanup(sigval):
time.sleep(0.5) time.sleep(0.5)
def main(): def main():
debug.reset_startup_timing()
setProcessName('cthulhu') setProcessName('cthulhu')
parser = Parser() parser = Parser()
@@ -333,16 +337,26 @@ def main():
debug.eventDebugLevel = debug.LEVEL_OFF debug.eventDebugLevel = debug.LEVEL_OFF
debug.debugFile = open(args.debug_file, 'w') debug.debugFile = open(args.debug_file, 'w')
debug.set_startup_timing_enabled(args.debug or args.debug_startup)
debug.print_startup_timing("launcher main start")
if args.replace: if args.replace:
with debug.startup_timing("replace cleanup"):
cleanup(signal.SIGKILL) cleanup(signal.SIGKILL)
settingsDict = getattr(args, 'settings', {}) settingsDict = getattr(args, 'settings', {})
if not inGraphicalDesktop(): with debug.startup_timing("graphical desktop check"):
graphicalDesktop = inGraphicalDesktop()
if not graphicalDesktop:
print(messages.CLI_NO_DESKTOP_ERROR) print(messages.CLI_NO_DESKTOP_ERROR)
return 1 return 1
with debug.startup_timing("session check"):
sessionType = getSessionType() sessionType = getSessionType()
xServerVendor = None
if sessionType == "x11":
xServerVendor = getXServerVendor()
sessionDetails = [] sessionDetails = []
xdgSessionType = os.environ.get("XDG_SESSION_TYPE") xdgSessionType = os.environ.get("XDG_SESSION_TYPE")
if xdgSessionType: if xdgSessionType:
@@ -355,9 +369,8 @@ def main():
display = os.environ.get("DISPLAY") display = os.environ.get("DISPLAY")
if display: if display:
sessionDetails.append(f"DISPLAY={display}") sessionDetails.append(f"DISPLAY={display}")
vendor = getXServerVendor() if xServerVendor:
if vendor: sessionDetails.append(f"X server vendor={xServerVendor}")
sessionDetails.append(f"X server vendor={vendor}")
if sessionDetails: if sessionDetails:
msg = f"INFO: Session: {sessionType} ({', '.join(sessionDetails)})" msg = f"INFO: Session: {sessionType} ({', '.join(sessionDetails)})"
@@ -367,7 +380,9 @@ def main():
debug.printMessage(debug.LEVEL_INFO, "INFO: Preparing to launch.", True) debug.printMessage(debug.LEVEL_INFO, "INFO: Preparing to launch.", True)
debug.print_startup_timing("cthulhu import start")
from cthulhu import cthulhu from cthulhu import cthulhu
debug.print_startup_timing("cthulhu import complete")
manager = cthulhu.cthulhuApp.settingsManager manager = cthulhu.cthulhuApp.settingsManager
if not manager: if not manager:
@@ -375,10 +390,12 @@ def main():
return 1 return 1
debug.printMessage(debug.LEVEL_INFO, "INFO: About to activate settings manager.", True) debug.printMessage(debug.LEVEL_INFO, "INFO: About to activate settings manager.", True)
with debug.startup_timing("settings manager launcher activation"):
manager.activate(args.user_prefs, settingsDict) manager.activate(args.user_prefs, settingsDict)
sys.path.insert(0, manager.getPrefsDir()) sys.path.insert(0, manager.getPrefsDir())
if args.profile: if args.profile:
with debug.startup_timing("profile selection"):
try: try:
manager.setProfile(args.profile) manager.setProfile(args.profile)
except Exception: except Exception:
@@ -386,14 +403,19 @@ def main():
manager.setProfile() manager.setProfile()
if args.setup: if args.setup:
with debug.startup_timing("setup cleanup"):
cleanup(signal.SIGKILL) cleanup(signal.SIGKILL)
with debug.startup_timing("preferences GUI startup"):
cthulhu.showPreferencesGUI() cthulhu.showPreferencesGUI()
if otherCthulhus(): with debug.startup_timing("other instance check"):
otherInstances = otherCthulhus()
if otherInstances:
print(messages.CLI_OTHER_CTHULHUS_ERROR) print(messages.CLI_OTHER_CTHULHUS_ERROR)
return 1 return 1
debug.printMessage(debug.LEVEL_INFO, "INFO: About to launch Cthulhu.", True) debug.printMessage(debug.LEVEL_INFO, "INFO: About to launch Cthulhu.", True)
debug.print_startup_timing("cthulhu main handoff")
return cthulhu.main() return cthulhu.main()
if __name__ == "__main__": if __name__ == "__main__":
+53 -5
View File
@@ -402,20 +402,24 @@ def loadUserSettings(script: Optional[Any] = None, inputEvent: Optional[Any] = N
""" """
debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Loading User Settings', True) debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Loading User Settings', True)
debug.print_startup_timing("load user settings start")
global _userSettings global _userSettings
# Shutdown the output drivers and give them a chance to die. # Shutdown the output drivers and give them a chance to die.
with debug.startup_timing("output driver shutdown"):
player = sound.getPlayer() player = sound.getPlayer()
player.shutdown() player.shutdown()
speech.shutdown() speech.shutdown()
braille.shutdown() braille.shutdown()
with debug.startup_timing("script manager deactivate for settings load"):
cthulhuApp.scriptManager.deactivate() cthulhuApp.scriptManager.deactivate()
cthulhuApp.getSignalManager().emitSignal('load-setting-begin') cthulhuApp.getSignalManager().emitSignal('load-setting-begin')
reloaded: bool = False reloaded: bool = False
with debug.startup_timing("settings profile load"):
if _userSettings: if _userSettings:
_profile = cthulhuApp.settingsManager.getSetting('activeProfile')[1] _profile = cthulhuApp.settingsManager.getSetting('activeProfile')[1]
try: try:
@@ -438,11 +442,13 @@ def loadUserSettings(script: Optional[Any] = None, inputEvent: Optional[Any] = N
if not script: if not script:
script = cthulhuApp.scriptManager.get_default_script() script = cthulhuApp.scriptManager.get_default_script()
with debug.startup_timing("app settings load"):
cthulhuApp.settingsManager.loadAppSettings(script) cthulhuApp.settingsManager.loadAppSettings(script)
if cthulhuApp.settingsManager.getSetting('enableSpeech'): if cthulhuApp.settingsManager.getSetting('enableSpeech'):
msg = 'CTHULHU: About to enable speech' msg = 'CTHULHU: About to enable speech'
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
with debug.startup_timing("speech initialization"):
try: try:
speech.init() speech.init()
if reloaded and not skipReloadMessage: if reloaded and not skipReloadMessage:
@@ -456,6 +462,7 @@ def loadUserSettings(script: Optional[Any] = None, inputEvent: Optional[Any] = N
if cthulhuApp.settingsManager.getSetting('enableBraille'): if cthulhuApp.settingsManager.getSetting('enableBraille'):
msg = 'CTHULHU: About to enable braille' msg = 'CTHULHU: About to enable braille'
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
with debug.startup_timing("braille initialization"):
try: try:
braille.init(_processBrailleEvent) braille.init(_processBrailleEvent)
except Exception: except Exception:
@@ -467,17 +474,21 @@ def loadUserSettings(script: Optional[Any] = None, inputEvent: Optional[Any] = N
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
with debug.startup_timing("mouse review update"):
if cthulhuApp.settingsManager.getSetting('enableMouseReview'): if cthulhuApp.settingsManager.getSetting('enableMouseReview'):
mouse_review.getReviewer().activate() mouse_review.getReviewer().activate()
else: else:
mouse_review.getReviewer().deactivate() mouse_review.getReviewer().deactivate()
if cthulhuApp.settingsManager.getSetting('enableSound'): if cthulhuApp.settingsManager.getSetting('enableSound'):
with debug.startup_timing("sound initialization"):
player.init() player.init()
with debug.startup_timing("cthulhu modifier refresh"):
cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Loading user settings.") cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Loading user settings.")
# Activate core systems FIRST before loading plugins # Activate core systems FIRST before loading plugins
with debug.startup_timing("core manager activation"):
cthulhuApp.compositorStateAdapter.activate() cthulhuApp.compositorStateAdapter.activate()
cthulhuApp.scriptManager.activate() cthulhuApp.scriptManager.activate()
cthulhuApp.eventManager.activate() cthulhuApp.eventManager.activate()
@@ -498,14 +509,17 @@ def loadUserSettings(script: Optional[Any] = None, inputEvent: Optional[Any] = N
) )
activePlugins = list(activePluginsSetting) activePlugins = list(activePluginsSetting)
debug.printMessage(debug.LEVEL_INFO, f'CTHULHU: Loading active plugins: {activePlugins}', True) debug.printMessage(debug.LEVEL_INFO, f'CTHULHU: Loading active plugins: {activePlugins}', True)
with debug.startup_timing("active plugin load"):
cthulhuApp.getPluginSystemManager().setActivePlugins(activePlugins) cthulhuApp.getPluginSystemManager().setActivePlugins(activePlugins)
# Refresh keybindings to include plugin bindings (after script manager is active) # Refresh keybindings to include plugin bindings (after script manager is active)
with debug.startup_timing("plugin keybinding refresh"):
cthulhuApp.getPluginSystemManager().refresh_active_script_keybindings() cthulhuApp.getPluginSystemManager().refresh_active_script_keybindings()
cthulhuApp.getSignalManager().emitSignal('load-setting-completed') cthulhuApp.getSignalManager().emitSignal('load-setting-completed')
debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: User Settings Loaded', True) debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: User Settings Loaded', True)
debug.print_startup_timing("load user settings complete")
return True return True
@@ -612,6 +626,7 @@ def init() -> bool:
""" """
debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Initializing', True) debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Initializing', True)
debug.print_startup_timing("cthulhu init start")
global _initialized global _initialized
@@ -627,8 +642,10 @@ def init() -> bool:
# Activate settings manager before loading user settings # Activate settings manager before loading user settings
debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Activating settings manager', True) debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Activating settings manager', True)
with debug.startup_timing("settings manager init activation"):
cthulhuApp.settingsManager.activate() cthulhuApp.settingsManager.activate()
with debug.startup_timing("load user settings"):
loadUserSettings() loadUserSettings()
if settings.timeoutCallback and (settings.timeoutTime > 0): if settings.timeoutCallback and (settings.timeoutTime > 0):
@@ -636,6 +653,7 @@ def init() -> bool:
_initialized = True _initialized = True
debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Initialized', True) debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Initialized', True)
debug.print_startup_timing("cthulhu init complete")
return True return True
@@ -672,6 +690,7 @@ def start() -> None:
"""Starts Cthulhu.""" """Starts Cthulhu."""
debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Starting', True) debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Starting', True)
debug.print_startup_timing("cthulhu start begin")
if not _initialized: if not _initialized:
init() init()
@@ -689,11 +708,13 @@ def start() -> None:
# Used to re-create the Xmodmap when a new keyboard is plugged in. # Used to re-create the Xmodmap when a new keyboard is plugged in.
# Necessary, because plugging in a new keyboard resets the Xmodmap # Necessary, because plugging in a new keyboard resets the Xmodmap
# and stomps our changes # and stomps our changes
with debug.startup_timing("input device monitor setup"):
display = Gdk.Display.get_default() display = Gdk.Display.get_default()
devmanager = display.get_device_manager() devmanager = display.get_device_manager()
devmanager.connect("device-added", deviceChangeHandler) devmanager.connect("device-added", deviceChangeHandler)
devmanager.connect("device-removed", deviceChangeHandler) devmanager.connect("device-removed", deviceChangeHandler)
with debug.startup_timing("desktop startup notification"):
Gdk.notify_startup_complete() Gdk.notify_startup_complete()
msg = 'CTHULHU: Startup complete notification made' msg = 'CTHULHU: Startup complete notification made'
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
@@ -701,9 +722,11 @@ def start() -> None:
debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Starting Atspi main event loop', True) debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Starting Atspi main event loop', True)
# Start D-Bus remote controller service after ATSPI is ready # Start D-Bus remote controller service after ATSPI is ready
with debug.startup_timing("startup idle services"):
cthulhuApp.getMakoNotificationMonitor().start() cthulhuApp.getMakoNotificationMonitor().start()
GObject.idle_add(_start_dbus_service) GObject.idle_add(_start_dbus_service)
debug.print_startup_timing("AT-SPI main loop start")
Atspi.event_main() Atspi.event_main()
def die(exitCode: int = 1) -> None: def die(exitCode: int = 1) -> None:
@@ -851,7 +874,9 @@ def main() -> int:
if session: if session:
msg += f" session: {session}" msg += f" session: {session}"
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
debug.print_startup_timing("cthulhu main start")
with debug.startup_timing("faulthandler setup"):
if debug.debugFile and os.path.exists(debug.debugFile.name): if debug.debugFile and os.path.exists(debug.debugFile.name):
faulthandler.enable(file=debug.debugFile, all_threads=True) faulthandler.enable(file=debug.debugFile, all_threads=True)
else: else:
@@ -863,6 +888,7 @@ def main() -> int:
# Various signal handlers we want to listen for. # Various signal handlers we want to listen for.
# #
with debug.startup_timing("signal handler setup"):
signal.signal(signal.SIGHUP, shutdownOnSignal) signal.signal(signal.SIGHUP, shutdownOnSignal)
signal.signal(signal.SIGINT, shutdownOnSignal) signal.signal(signal.SIGINT, shutdownOnSignal)
signal.signal(signal.SIGTERM, shutdownOnSignal) signal.signal(signal.SIGTERM, shutdownOnSignal)
@@ -870,14 +896,17 @@ def main() -> int:
signal.signal(signal.SIGSEGV, crashOnSignal) signal.signal(signal.SIGSEGV, crashOnSignal)
debug.printMessage(debug.LEVEL_INFO, "CTHULHU: Enabling accessibility (if needed).", True) debug.printMessage(debug.LEVEL_INFO, "CTHULHU: Enabling accessibility (if needed).", True)
with debug.startup_timing("accessibility enable check"):
if not cthulhuApp.settingsManager.isAccessibilityEnabled(): if not cthulhuApp.settingsManager.isAccessibilityEnabled():
cthulhuApp.settingsManager.setAccessibility(True) cthulhuApp.settingsManager.setAccessibility(True)
debug.printMessage(debug.LEVEL_INFO, "CTHULHU: Initializing.", True) debug.printMessage(debug.LEVEL_INFO, "CTHULHU: Initializing.", True)
with debug.startup_timing("cthulhu init"):
init() init()
debug.printMessage(debug.LEVEL_INFO, "CTHULHU: Initialized.", True) debug.printMessage(debug.LEVEL_INFO, "CTHULHU: Initialized.", True)
script = cthulhu_state.activeScript script = cthulhu_state.activeScript
with debug.startup_timing("startup sound"):
sound_theme_manager.getManager().playStartSound(wait=False) sound_theme_manager.getManager().playStartSound(wait=False)
soundFailureReason = sound.getSoundSystemFailureReason() soundFailureReason = sound.getSoundSystemFailureReason()
if soundFailureReason: if soundFailureReason:
@@ -886,6 +915,7 @@ def main() -> int:
f"CTHULHU: Startup sound failed. Continuing without sound. Reason: {soundFailureReason}", f"CTHULHU: Startup sound failed. Continuing without sound. Reason: {soundFailureReason}",
True True
) )
with debug.startup_timing("startup focus recovery"):
cthulhuApp.getSignalManager().emitSignal('start-application-completed') cthulhuApp.getSignalManager().emitSignal('start-application-completed')
if script: if script:
window = script.utilities.activeWindow() window = script.utilities.activeWindow()
@@ -910,6 +940,7 @@ def main() -> int:
try: try:
msg = "CTHULHU: Starting ATSPI registry." msg = "CTHULHU: Starting ATSPI registry."
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
debug.print_startup_timing("AT-SPI registry start")
start() # waits until we stop the registry start() # waits until we stop the registry
except Exception: except Exception:
msg = "CTHULHU: Exception starting ATSPI registry." msg = "CTHULHU: Exception starting ATSPI registry."
@@ -930,27 +961,44 @@ class Cthulhu(GObject.Object):
"active-script-changed": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT,)), # New signal to indicate active script change "active-script-changed": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT,)), # New signal to indicate active script change
} }
def __init__(self) -> None: def __init__(self) -> None:
debug.print_startup_timing("cthulhuApp construction start")
GObject.Object.__init__(self) GObject.Object.__init__(self)
# add members # add members
with debug.startup_timing("resource manager construction"):
self.resourceManager: resource_manager.ResourceManager = resource_manager.ResourceManager(self) self.resourceManager: resource_manager.ResourceManager = resource_manager.ResourceManager(self)
self.settingsManager: SettingsManager = settings_manager.SettingsManager(self) # Directly instantiate with debug.startup_timing("settings manager construction"):
self.eventManager: EventManager = event_manager.EventManager(self) # Directly instantiate self.settingsManager: SettingsManager = settings_manager.SettingsManager(self)
self.compositorStateAdapter: CompositorStateAdapter = compositor_state_adapter.CompositorStateAdapter() with debug.startup_timing("event manager construction"):
self.eventManager: EventManager = event_manager.EventManager(self)
with debug.startup_timing("compositor adapter construction"):
self.compositorStateAdapter: CompositorStateAdapter = \
compositor_state_adapter.CompositorStateAdapter()
self.eventManager.set_compositor_state_adapter(self.compositorStateAdapter) self.eventManager.set_compositor_state_adapter(self.compositorStateAdapter)
self.scriptManager: ScriptManager = script_manager.ScriptManager(self) # Directly instantiate with debug.startup_timing("script manager construction"):
self.scriptManager: ScriptManager = script_manager.ScriptManager(self)
script_manager._manager = self.scriptManager script_manager._manager = self.scriptManager
self.logger: logger.Logger = logger.Logger() # Directly instantiate with debug.startup_timing("logger construction"):
self.logger: logger.Logger = logger.Logger()
with debug.startup_timing("signal manager construction"):
self.signalManager: SignalManager = signal_manager.SignalManager(self) self.signalManager: SignalManager = signal_manager.SignalManager(self)
with debug.startup_timing("dynamic API manager construction"):
self.dynamicApiManager: DynamicApiManager = dynamic_api_manager.DynamicApiManager(self) self.dynamicApiManager: DynamicApiManager = dynamic_api_manager.DynamicApiManager(self)
with debug.startup_timing("translation manager construction"):
self.translationManager: TranslationManager = translation_manager.TranslationManager(self) self.translationManager: TranslationManager = translation_manager.TranslationManager(self)
self.debugManager: Any = debug self.debugManager: Any = debug
with debug.startup_timing("API helper construction"):
self.APIHelper: APIHelper = APIHelper(self) self.APIHelper: APIHelper = APIHelper(self)
with debug.startup_timing("notification monitor construction"):
self.makoNotificationMonitor: mako_notification_monitor.MakoNotificationMonitor = \ self.makoNotificationMonitor: mako_notification_monitor.MakoNotificationMonitor = \
mako_notification_monitor.MakoNotificationMonitor(self) mako_notification_monitor.MakoNotificationMonitor(self)
with debug.startup_timing("compat API registration"):
self.createCompatAPI() self.createCompatAPI()
with debug.startup_timing("plugin manager construction"):
self.pluginSystemManager: PluginSystemManager = plugin_system_manager.PluginSystemManager(self) self.pluginSystemManager: PluginSystemManager = plugin_system_manager.PluginSystemManager(self)
# Scan for available plugins at startup # Scan for available plugins at startup
with debug.startup_timing("plugin scan"):
self.pluginSystemManager.rescanPlugins() self.pluginSystemManager.rescanPlugins()
debug.print_startup_timing("cthulhuApp construction complete")
def getAPIHelper(self) -> APIHelper: def getAPIHelper(self) -> APIHelper:
return self.APIHelper return self.APIHelper
+27 -5
View File
@@ -154,6 +154,8 @@ class Parser(argparse.ArgumentParser):
help=messages.CLI_DEBUG_FILE, metavar=messages.CLI_DEBUG_FILE_NAME) help=messages.CLI_DEBUG_FILE, metavar=messages.CLI_DEBUG_FILE_NAME)
self.add_argument( self.add_argument(
"--debug", action="store_true", help=messages.CLI_ENABLE_DEBUG) "--debug", action="store_true", help=messages.CLI_ENABLE_DEBUG)
self.add_argument(
"--debug-startup", action="store_true", help=messages.CLI_ENABLE_STARTUP_DEBUG)
self._optionals.title = messages.CLI_OPTIONAL_ARGUMENTS self._optionals.title = messages.CLI_OPTIONAL_ARGUMENTS
@@ -281,6 +283,8 @@ def cleanup(sigval):
time.sleep(0.5) time.sleep(0.5)
def main(): def main():
debug.reset_startup_timing()
setProcessName('cthulhu') setProcessName('cthulhu')
parser = Parser() parser = Parser()
@@ -291,16 +295,26 @@ def main():
debug.eventDebugLevel = debug.LEVEL_OFF debug.eventDebugLevel = debug.LEVEL_OFF
debug.debugFile = open(args.debug_file, 'w') debug.debugFile = open(args.debug_file, 'w')
debug.set_startup_timing_enabled(args.debug or args.debug_startup)
debug.print_startup_timing("launcher main start")
if args.replace: if args.replace:
with debug.startup_timing("replace cleanup"):
cleanup(signal.SIGKILL) cleanup(signal.SIGKILL)
settingsDict = getattr(args, 'settings', {}) settingsDict = getattr(args, 'settings', {})
if not inGraphicalDesktop(): with debug.startup_timing("graphical desktop check"):
graphicalDesktop = inGraphicalDesktop()
if not graphicalDesktop:
print(messages.CLI_NO_DESKTOP_ERROR) print(messages.CLI_NO_DESKTOP_ERROR)
return 1 return 1
with debug.startup_timing("session check"):
sessionType = getSessionType() sessionType = getSessionType()
xServerVendor = None
if sessionType == "x11":
xServerVendor = getXServerVendor()
sessionDetails = [] sessionDetails = []
xdgSessionType = os.environ.get("XDG_SESSION_TYPE") xdgSessionType = os.environ.get("XDG_SESSION_TYPE")
if xdgSessionType: if xdgSessionType:
@@ -313,9 +327,8 @@ def main():
display = os.environ.get("DISPLAY") display = os.environ.get("DISPLAY")
if display: if display:
sessionDetails.append(f"DISPLAY={display}") sessionDetails.append(f"DISPLAY={display}")
vendor = getXServerVendor() if xServerVendor:
if vendor: sessionDetails.append(f"X server vendor={xServerVendor}")
sessionDetails.append(f"X server vendor={vendor}")
if sessionDetails: if sessionDetails:
msg = f"INFO: Session: {sessionType} ({', '.join(sessionDetails)})" msg = f"INFO: Session: {sessionType} ({', '.join(sessionDetails)})"
@@ -325,7 +338,9 @@ def main():
debug.printMessage(debug.LEVEL_INFO, "INFO: Preparing to launch.", True) debug.printMessage(debug.LEVEL_INFO, "INFO: Preparing to launch.", True)
debug.print_startup_timing("cthulhu import start")
from cthulhu import cthulhu from cthulhu import cthulhu
debug.print_startup_timing("cthulhu import complete")
manager = cthulhu.cthulhuApp.settingsManager manager = cthulhu.cthulhuApp.settingsManager
if not manager: if not manager:
@@ -333,10 +348,12 @@ def main():
return 1 return 1
debug.printMessage(debug.LEVEL_INFO, "INFO: About to activate settings manager.", True) debug.printMessage(debug.LEVEL_INFO, "INFO: About to activate settings manager.", True)
with debug.startup_timing("settings manager launcher activation"):
manager.activate(args.user_prefs, settingsDict) manager.activate(args.user_prefs, settingsDict)
sys.path.insert(0, manager.getPrefsDir()) sys.path.insert(0, manager.getPrefsDir())
if args.profile: if args.profile:
with debug.startup_timing("profile selection"):
try: try:
manager.setProfile(args.profile) manager.setProfile(args.profile)
except Exception: except Exception:
@@ -344,14 +361,19 @@ def main():
manager.setProfile() manager.setProfile()
if args.setup: if args.setup:
with debug.startup_timing("setup cleanup"):
cleanup(signal.SIGKILL) cleanup(signal.SIGKILL)
with debug.startup_timing("preferences GUI startup"):
cthulhu.showPreferencesGUI() cthulhu.showPreferencesGUI()
if otherCthulhus(): with debug.startup_timing("other instance check"):
otherInstances = otherCthulhus()
if otherInstances:
print(messages.CLI_OTHER_CTHULHUS_ERROR) print(messages.CLI_OTHER_CTHULHUS_ERROR)
return 1 return 1
debug.printMessage(debug.LEVEL_INFO, "INFO: About to launch Cthulhu.", True) debug.printMessage(debug.LEVEL_INFO, "INFO: About to launch Cthulhu.", True)
debug.print_startup_timing("cthulhu main handoff")
return cthulhu.main() return cthulhu.main()
if __name__ == "__main__": if __name__ == "__main__":
+74 -1
View File
@@ -37,13 +37,15 @@ __copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc."
__license__ = "LGPL" __license__ = "LGPL"
import inspect import inspect
from contextlib import contextmanager
import traceback import traceback
import os import os
import re import re
import subprocess import subprocess
import sys import sys
import time
import types import types
from typing import Any, Optional, TextIO, Pattern, TYPE_CHECKING from typing import Any, Generator, Optional, TextIO, Pattern, TYPE_CHECKING
from datetime import datetime from datetime import datetime
import gi import gi
@@ -149,6 +151,13 @@ eventDebugFilter: Optional[Pattern[str]] = None
# #
debugEventQueue: bool = False debugEventQueue: bool = False
# Startup timing is intentionally separate from normal event tracing. When
# explicitly enabled, it can print concise phase timings without turning on the
# full debug stream.
startupTimingEnabled: bool = False
_startupTimingStartTime: Optional[float] = None
_startupTimingLastTime: Optional[float] = None
# What module(s) should be traced if traceit is being used. By default # What module(s) should be traced if traceit is being used. By default
# we'll just attend to ourself. (And by default, we will not enable # we'll just attend to ourself. (And by default, we will not enable
# traceit.) Note that enabling this functionality will drag your system # traceit.) Note that enabling this functionality will drag your system
@@ -282,6 +291,70 @@ def print_log_tokens(level: int, prefix: str, tokens: list[Any], reason: Optiona
text = format_log_message(prefix, _format_tokens(tokens), reason) text = format_log_message(prefix, _format_tokens(tokens), reason)
printMessage(level, text, timestamp, stack) printMessage(level, text, timestamp, stack)
def reset_startup_timing() -> None:
"""Reset the startup timing baseline."""
global _startupTimingStartTime
global _startupTimingLastTime
now = time.monotonic()
_startupTimingStartTime = now
_startupTimingLastTime = now
def set_startup_timing_enabled(enabled: bool = True) -> None:
"""Enable or disable concise startup phase timing output."""
global startupTimingEnabled
startupTimingEnabled = enabled
if enabled and _startupTimingStartTime is None:
reset_startup_timing()
def is_startup_timing_enabled() -> bool:
"""Returns True when startup timing output should be emitted."""
return startupTimingEnabled or debugLevel <= LEVEL_INFO
def _startup_timing_level() -> int:
if debugLevel <= LEVEL_INFO:
return LEVEL_INFO
return LEVEL_SEVERE
def print_startup_timing(phase: str) -> None:
"""Print elapsed startup timing for a named phase."""
global _startupTimingStartTime
global _startupTimingLastTime
if not is_startup_timing_enabled():
return
now = time.monotonic()
if _startupTimingStartTime is None or _startupTimingLastTime is None:
_startupTimingStartTime = now
_startupTimingLastTime = now
delta = now - _startupTimingLastTime
total = now - _startupTimingStartTime
_startupTimingLastTime = now
text = f"STARTUP TIMING: {phase}: +{delta:.3f}s, total {total:.3f}s"
printMessage(_startup_timing_level(), text, True)
@contextmanager
def startup_timing(phase: str) -> Generator[None, None, None]:
"""Context manager for timing a startup phase."""
if not is_startup_timing_enabled():
yield
return
print_startup_timing(f"{phase} start")
try:
yield
finally:
print_startup_timing(f"{phase} end")
def printTokens(level: int, tokens: list[Any], timestamp: bool = False, stack: bool = False) -> None: def printTokens(level: int, tokens: list[Any], timestamp: bool = False, stack: bool = False) -> None:
if level < debugLevel: if level < debugLevel:
return return
+5
View File
@@ -293,6 +293,11 @@ CLI_HELP = _("Show this help message and exit")
# start with 'debug' and end with '.out', regardless of the locale.). # start with 'debug' and end with '.out', regardless of the locale.).
CLI_ENABLE_DEBUG = _("Send debug output to debug-YYYY-MM-DD-HH:MM:SS.out") CLI_ENABLE_DEBUG = _("Send debug output to debug-YYYY-MM-DD-HH:MM:SS.out")
# Translators: This is the description of command line option '--debug-startup'
# which causes Cthulhu to print concise startup timing information without
# enabling the full debug output stream.
CLI_ENABLE_STARTUP_DEBUG = _("Send concise startup timing output")
# Translators: This is the description of command line option '--debug-file' # Translators: This is the description of command line option '--debug-file'
# which allows the user to override the default date-based name of the debugging # which allows the user to override the default date-based name of the debugging
# output file. # output file.
+15 -6
View File
@@ -545,31 +545,40 @@ class SpeechServer(speechserver.SpeechServer):
- utteranceIterator: Iterator yielding [SayAllContext, acss] tuples - utteranceIterator: Iterator yielding [SayAllContext, acss] tuples
- progressCallback: Called with progress updates - progressCallback: Called with progress updates
""" """
GLib.idle_add(self._sayAllWorker, utteranceIterator, progressCallback) with self._lock:
self._stopEvent.clear()
self._speakGeneration += 1
generation = self._speakGeneration
GLib.idle_add(self._sayAllWorker, utteranceIterator, progressCallback, generation)
def _sayAllWorker(self, iterator, callback): def _sayAllWorker(self, iterator, callback, generation):
"""Process one utterance at a time (called via GLib.idle_add). """Process one utterance at a time (called via GLib.idle_add).
Arguments: Arguments:
- iterator: Utterance iterator - iterator: Utterance iterator
- callback: Progress callback - callback: Progress callback
""" """
with self._lock:
if self._stopEvent.is_set() or generation != self._speakGeneration:
return False
try: try:
context, acss = next(iterator) context, acss = next(iterator)
except StopIteration: except StopIteration:
return False return False
def onComplete(): def onComplete():
with self._lock:
if self._stopEvent.is_set() or generation != self._speakGeneration:
return False
context.currentOffset = context.endOffset context.currentOffset = context.endOffset
callback(context.copy(), speechserver.SayAllContext.COMPLETED) callback(context.copy(), speechserver.SayAllContext.COMPLETED)
GLib.idle_add(self._sayAllWorker, iterator, callback) GLib.idle_add(self._sayAllWorker, iterator, callback, generation)
return False
context.currentOffset = context.startOffset context.currentOffset = context.startOffset
callback(context.copy(), speechserver.SayAllContext.PROGRESS) callback(context.copy(), speechserver.SayAllContext.PROGRESS)
with self._lock:
self._stopEvent.clear()
generation = self._speakGeneration
self._currentFuture = self._executor.submit( self._currentFuture = self._executor.submit(
self._synthesizeAndPlay, context.utterance, acss, onComplete, generation self._synthesizeAndPlay, context.utterance, acss, onComplete, generation
) )
+34 -4
View File
@@ -165,6 +165,8 @@ class SpeechServer(speechserver.SpeechServer):
SpeechServer._active_servers[serverId] = self SpeechServer._active_servers[serverId] = self
self._lastKeyEchoTime = None self._lastKeyEchoTime = None
self._say_all_generation = 0
self._cancelled_say_all_generation = None
def _init(self): def _init(self):
self._client = client = speechd.SSIPClient('Cthulhu', component=self._id) self._client = client = speechd.SSIPClient('Cthulhu', component=self._id)
@@ -489,22 +491,42 @@ class SpeechServer(speechserver.SpeechServer):
self._debug_sd_values(f"Speaking '{ssml}' ") self._debug_sd_values(f"Speaking '{ssml}' ")
self._send_command(self._client.speak, ssml, **kwargs) self._send_command(self._client.speak, ssml, **kwargs)
def _say_all(self, iterator, cthulhu_callback): def _say_all(self, iterator, cthulhu_callback, generation):
"""Process another sayAll chunk. """Process another sayAll chunk.
Called by the gidle thread. Called by the gidle thread.
""" """
if generation != self._say_all_generation:
return False
if generation == self._cancelled_say_all_generation:
return False
try: try:
context, acss = next(iterator) context, acss = next(iterator)
except StopIteration: except StopIteration:
pass pass
else: else:
def dispatch_callback(context_copy, progress_type, allow_interrupted=False):
if not allow_interrupted and generation != self._say_all_generation:
return False
cthulhu_callback(context_copy, progress_type)
return False
def callback(callbackType, index_mark=None): def callback(callbackType, index_mark=None):
# This callback is called in Speech Dispatcher listener thread. # This callback is called in Speech Dispatcher listener thread.
# No subsequent Speech Dispatcher interaction is allowed here, # No subsequent Speech Dispatcher interaction is allowed here,
# so we pass the calls to the gidle thread. # so we pass the calls to the gidle thread.
t = self._CALLBACK_TYPE_MAP[callbackType] t = self._CALLBACK_TYPE_MAP[callbackType]
if generation != self._say_all_generation:
return
if generation == self._cancelled_say_all_generation:
if t == speechserver.SayAllContext.INTERRUPTED:
self._say_all_generation += 1
self._cancelled_say_all_generation = None
GLib.idle_add(dispatch_callback, context.copy(), t, True)
return
if t == speechserver.SayAllContext.PROGRESS: if t == speechserver.SayAllContext.PROGRESS:
if index_mark: if index_mark:
index = index_mark.split(':') index = index_mark.split(':')
@@ -524,14 +546,15 @@ class SpeechServer(speechserver.SpeechServer):
elif t == speechserver.SayAllContext.COMPLETED: elif t == speechserver.SayAllContext.COMPLETED:
context.currentOffset = context.endOffset context.currentOffset = context.endOffset
context.currentEndOffset = None context.currentEndOffset = None
GLib.idle_add(cthulhu_callback, context.copy(), t) GLib.idle_add(dispatch_callback, context.copy(), t)
if t == speechserver.SayAllContext.COMPLETED: if t == speechserver.SayAllContext.COMPLETED:
GLib.idle_add(self._say_all, iterator, cthulhu_callback) GLib.idle_add(self._say_all, iterator, cthulhu_callback, generation)
self._speak(context.utterance, acss, callback=callback, self._speak(context.utterance, acss, callback=callback,
event_types=list(self._CALLBACK_TYPE_MAP.keys())) event_types=list(self._CALLBACK_TYPE_MAP.keys()))
return False # to indicate, that we don't want to be called again. return False # to indicate, that we don't want to be called again.
def _cancel(self): def _cancel(self):
self._cancelled_say_all_generation = self._say_all_generation
self._send_command(self._client.cancel) self._send_command(self._client.cancel)
def _change_default_speech_rate(self, step, decrease=False): def _change_default_speech_rate(self, step, decrease=False):
@@ -657,7 +680,14 @@ class SpeechServer(speechserver.SpeechServer):
self._speak(text, acss) self._speak(text, acss)
def sayAll(self, utteranceIterator, progressCallback): def sayAll(self, utteranceIterator, progressCallback):
GLib.idle_add(self._say_all, utteranceIterator, progressCallback) self._say_all_generation += 1
self._cancelled_say_all_generation = None
GLib.idle_add(
self._say_all,
utteranceIterator,
progressCallback,
self._say_all_generation,
)
def speakCharacter(self, character, acss=None): def speakCharacter(self, character, acss=None):
self._apply_acss(acss) self._apply_acss(acss)
+79
View File
@@ -0,0 +1,79 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cthulhu import debug
class DebugStartupTimingTests(unittest.TestCase):
def setUp(self) -> None:
self._debugLevel = debug.debugLevel
self._startupTimingEnabled = debug.startupTimingEnabled
self._startupTimingStartTime = debug._startupTimingStartTime
self._startupTimingLastTime = debug._startupTimingLastTime
debug.debugLevel = debug.LEVEL_SEVERE
debug.startupTimingEnabled = False
debug._startupTimingStartTime = None
debug._startupTimingLastTime = None
def tearDown(self) -> None:
debug.debugLevel = self._debugLevel
debug.startupTimingEnabled = self._startupTimingEnabled
debug._startupTimingStartTime = self._startupTimingStartTime
debug._startupTimingLastTime = self._startupTimingLastTime
def test_startup_timing_is_silent_when_disabled_and_debug_is_not_enabled(self) -> None:
with mock.patch.object(debug, "println") as println:
debug.print_startup_timing("hidden phase")
println.assert_not_called()
def test_startup_timing_logs_when_explicitly_enabled(self) -> None:
with mock.patch.object(debug.time, "monotonic", side_effect=[10.0, 10.25]):
debug.set_startup_timing_enabled(True)
with mock.patch.object(debug, "println") as println:
debug.print_startup_timing("explicit phase")
println.assert_called_once_with(
debug.LEVEL_SEVERE,
"STARTUP TIMING: explicit phase: +0.250s, total 0.250s",
True,
False,
)
def test_startup_timing_logs_when_debug_output_is_enabled(self) -> None:
debug.debugLevel = debug.LEVEL_INFO
with mock.patch.object(debug.time, "monotonic", return_value=20.0):
with mock.patch.object(debug, "println") as println:
debug.print_startup_timing("debug phase")
println.assert_called_once_with(
debug.LEVEL_INFO,
"STARTUP TIMING: debug phase: +0.000s, total 0.000s",
True,
False,
)
def test_startup_timing_context_logs_start_and_end(self) -> None:
with mock.patch.object(debug.time, "monotonic", side_effect=[30.0, 30.1, 30.4]):
debug.set_startup_timing_enabled(True)
with mock.patch.object(debug, "println") as println:
with debug.startup_timing("wrapped phase"):
pass
self.assertEqual(2, println.call_count)
self.assertEqual(
"STARTUP TIMING: wrapped phase start: +0.100s, total 0.100s",
println.call_args_list[0].args[1],
)
self.assertEqual(
"STARTUP TIMING: wrapped phase end: +0.300s, total 0.400s",
println.call_args_list[1].args[1],
)
if __name__ == "__main__":
unittest.main()
+48
View File
@@ -1,10 +1,13 @@
import sys import sys
import threading
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cthulhu import piperfactory from cthulhu import piperfactory
from cthulhu import speechserver
class PiperFactoryRateMappingTests(unittest.TestCase): class PiperFactoryRateMappingTests(unittest.TestCase):
@@ -25,6 +28,51 @@ class PiperFactoryRateMappingTests(unittest.TestCase):
self.assertEqual(2.0, self.server._mapRate(-1)) self.assertEqual(2.0, self.server._mapRate(-1))
self.assertEqual(0.25, self.server._mapRate(101)) self.assertEqual(0.25, self.server._mapRate(101))
def _make_say_all_server(self):
server = piperfactory.SpeechServer.__new__(piperfactory.SpeechServer)
server._lock = threading.Lock()
server._stopEvent = threading.Event()
server._speakGeneration = 1
server._currentFuture = None
server._executor = mock.Mock()
server._audioPlayer = None
return server
def _make_say_all_context(self, utterance):
return speechserver.SayAllContext(
mock.Mock(),
utterance,
0,
len(utterance),
)
def test_stop_invalidates_pending_say_all_worker(self):
server = self._make_say_all_server()
iterator = iter([[self._make_say_all_context("first"), None]])
callback = mock.Mock()
server.stop()
result = server._sayAllWorker(iterator, callback, 1)
self.assertFalse(result)
callback.assert_not_called()
server._executor.submit.assert_not_called()
def test_stale_say_all_completion_does_not_advance(self):
server = self._make_say_all_server()
first = self._make_say_all_context("first")
second = self._make_say_all_context("second")
iterator = iter([[first, None], [second, None]])
callback = mock.Mock()
server._sayAllWorker(iterator, callback, 1)
onComplete = server._executor.submit.call_args.args[3]
server.stop()
result = onComplete()
self.assertFalse(result)
self.assertEqual(1, server._executor.submit.call_count)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -7,6 +7,7 @@ from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cthulhu import speechdispatcherfactory from cthulhu import speechdispatcherfactory
from cthulhu import speechserver
import speechd import speechd
@@ -20,8 +21,24 @@ class SpeechDispatcherInterruptRegressionTests(unittest.TestCase):
server._speak = mock.Mock() server._speak = mock.Mock()
server._client = mock.Mock() server._client = mock.Mock()
server._client.char = mock.Mock() server._client.char = mock.Mock()
server._say_all_generation = 0
server._cancelled_say_all_generation = None
server._CALLBACK_TYPE_MAP = {
speechd.CallbackType.BEGIN: speechserver.SayAllContext.PROGRESS,
speechd.CallbackType.CANCEL: speechserver.SayAllContext.INTERRUPTED,
speechd.CallbackType.END: speechserver.SayAllContext.COMPLETED,
speechd.CallbackType.INDEX_MARK: speechserver.SayAllContext.PROGRESS,
}
return server return server
def _make_say_all_context(self, utterance):
return speechserver.SayAllContext(
mock.Mock(),
utterance,
0,
len(utterance),
)
def test_interrupting_string_speech_cancels_active_output_first(self): def test_interrupting_string_speech_cancels_active_output_first(self):
server = self._make_server() server = self._make_server()
@@ -47,6 +64,41 @@ class SpeechDispatcherInterruptRegressionTests(unittest.TestCase):
server._cancel.assert_not_called() server._cancel.assert_not_called()
server._speak.assert_called_once_with("next", None) server._speak.assert_called_once_with("next", None)
def test_stop_invalidates_pending_say_all_continuation(self):
server = self._make_server()
first = self._make_say_all_context("first")
second = self._make_say_all_context("second")
callback = mock.Mock()
iterator = iter([[first, None], [second, None]])
server.sayAll(iterator, callback)
server._say_all(iterator, callback, 1)
speechd_callback = server._speak.call_args.kwargs["callback"]
server.stop()
speechd_callback(speechd.CallbackType.END)
self.assertEqual(1, server._speak.call_count)
callback.assert_not_called()
def test_interrupting_speech_invalidates_pending_say_all_continuation(self):
server = self._make_server()
first = self._make_say_all_context("first")
second = self._make_say_all_context("second")
callback = mock.Mock()
iterator = iter([[first, None], [second, None]])
server.sayAll(iterator, callback)
server._say_all(iterator, callback, 1)
speechd_callback = server._speak.call_args.kwargs["callback"]
server.speak("new speech", interrupt=True)
speechd_callback(speechd.CallbackType.END)
self.assertEqual(2, server._speak.call_count)
self.assertEqual("new speech", server._speak.call_args.args[0])
callback.assert_not_called()
def test_send_command_logs_and_recovers_from_command_errors(self): def test_send_command_logs_and_recovers_from_command_errors(self):
server = self._make_server() server = self._make_server()
server.reset = mock.Mock() server.reset = mock.Mock()