diff --git a/src/mini_eq/app.py b/src/mini_eq/app.py index 81edf7f..e514289 100644 --- a/src/mini_eq/app.py +++ b/src/mini_eq/app.py @@ -23,15 +23,28 @@ set_background_status, ) from .cli import parse_args +from .core import AudioBackendError from .dbus_control import MiniEqDbusControl, call_present_window from .desktop_integration import APP_ICON_NAME, APP_ID, install_app_icon, install_desktop_integration from .glib_utils import destroy_glib_source from .instance import MiniEqAlreadyRunningError, MiniEqInstanceGuard +from .pipewire_backend import PipeWireBackendError from .routing import SystemWideEqController from .window import MiniEqWindow from .window_presets import imported_apo_curve_label STARTUP_NOTIFICATION_ENV_KEYS = ("XDG_ACTIVATION_TOKEN", "DESKTOP_STARTUP_ID") +STARTUP_AUTO_ROUTE_RETRY_INTERVAL_SECONDS = 1 +STARTUP_AUTO_ROUTE_RETRY_TIMEOUT_US = 30_000_000 +STARTUP_AUTO_ROUTE_RETRYABLE_PIPEWIRE_PREFIXES = ( + "failed to connect to PipeWire", + "failed to start PipeWire registry discovery", + "failed to start PipeWire default metadata discovery", + "PipeWire registry sync failed", + "PipeWire metadata sync failed", + "PipeWire core sync failed", + "PipeWire initialization did not report:", +) class MiniEqApplication(Adw.Application): @@ -45,6 +58,9 @@ def __init__(self, args: Namespace, startup_notification_id: str | None = None) self.window_present_source_id = 0 self.window_starting = False self.window_start_hold = False + self.window_start_retry_source_id = 0 + self.window_start_retry_deadline_us = 0 + self.window_start_last_error: Exception | None = None self.pending_present_when_ready = False self.pending_startup_notification_id = ( None if bool(getattr(args, "background", False)) else startup_notification_id @@ -107,6 +123,77 @@ def ensure_window(self, *, present: bool, startup_id: str | None = None) -> None self.pending_present_when_ready = self.pending_present_when_ready or present return + self.begin_window_start(present) + self.start_window_controller() + + def begin_window_start(self, present: bool) -> None: + self.window_starting = True + self.window_start_hold = True + self.pending_present_when_ready = present + if self.should_retry_startup_auto_route(): + self.window_start_retry_deadline_us = GLib.get_monotonic_time() + STARTUP_AUTO_ROUTE_RETRY_TIMEOUT_US + else: + self.window_start_retry_deadline_us = 0 + self.hold() + + def release_window_start_hold(self) -> None: + if not self.window_start_hold: + return + + self.window_start_hold = False + self.release() + + def should_retry_startup_auto_route(self) -> bool: + return bool(getattr(self.args, "auto_route", False)) + + def is_startup_auto_route_retryable_error(self, exc: Exception) -> bool: + message = str(exc) + if isinstance(exc, AudioBackendError): + return message.startswith("output sink not found:") + + if isinstance(exc, PipeWireBackendError): + return message.startswith(STARTUP_AUTO_ROUTE_RETRYABLE_PIPEWIRE_PREFIXES) + + return False + + def retry_startup_auto_route_after_error(self, exc: Exception) -> bool: + if not self.should_retry_startup_auto_route() or not self.is_startup_auto_route_retryable_error(exc): + return False + + deadline_us = getattr(self, "window_start_retry_deadline_us", 0) + if deadline_us <= 0 or GLib.get_monotonic_time() >= deadline_us: + return False + + self.window_start_last_error = exc + if self.window_start_retry_source_id == 0: + self.window_start_retry_source_id = GLib.timeout_add_seconds( + STARTUP_AUTO_ROUTE_RETRY_INTERVAL_SECONDS, + self.on_window_start_retry_timeout, + ) + return True + + def on_window_start_retry_timeout(self) -> bool: + self.window_start_retry_source_id = 0 + if not self.window_starting or self.window is not None: + return False + + self.start_window_controller() + return False + + def fail_window_start(self, exc: Exception) -> None: + self.window_starting = False + self.pending_present_when_ready = False + print(str(exc), file=sys.stderr) + self.release_window_start_hold() + self.quit() + + def raise_window_start_error(self, exc: Exception) -> None: + self.window_starting = False + self.pending_present_when_ready = False + self.release_window_start_hold() + raise SystemExit(str(exc)) from exc + + def start_window_controller(self) -> None: controller: SystemWideEqController | None = None initial_curve_label: str | None = None @@ -118,20 +205,15 @@ def ensure_window(self, *, present: bool, startup_id: str | None = None) -> None except Exception as exc: if controller is not None: controller.shutdown() - raise SystemExit(str(exc)) from exc - - self.controller = controller - self.window_starting = True - self.window_start_hold = True - self.pending_present_when_ready = present - self.hold() - - def release_start_hold() -> None: - if not self.window_start_hold: + if self.retry_startup_auto_route_after_error(exc): return + if self.should_retry_startup_auto_route() and self.is_startup_auto_route_retryable_error(exc): + self.fail_window_start(exc) + return + self.raise_window_start_error(exc) + return - self.window_start_hold = False - self.release() + self.controller = controller def on_ready() -> None: if self.controller is not controller: @@ -152,19 +234,17 @@ def on_ready() -> None: self.update_background_status() self.emit_control_state_changed() finally: - release_start_hold() + self.release_window_start_hold() def on_error(exc: Exception) -> None: - self.window_starting = False - self.pending_present_when_ready = False try: controller.shutdown() finally: if self.controller is controller: self.controller = None - print(str(exc), file=sys.stderr) - release_start_hold() - self.quit() + if self.retry_startup_auto_route_after_error(exc): + return + self.fail_window_start(exc) controller.start(on_ready=on_ready, on_error=on_error) @@ -248,6 +328,9 @@ def do_shutdown(self) -> None: for source_id in self.signal_source_ids: destroy_glib_source(source_id) self.signal_source_ids = [] + if self.window_start_retry_source_id > 0: + destroy_glib_source(self.window_start_retry_source_id) + self.window_start_retry_source_id = 0 if self.window_present_source_id > 0: destroy_glib_source(self.window_present_source_id) self.window_present_source_id = 0 diff --git a/src/mini_eq/routing.py b/src/mini_eq/routing.py index b6810f0..29a6c31 100644 --- a/src/mini_eq/routing.py +++ b/src/mini_eq/routing.py @@ -59,56 +59,60 @@ class SystemWideEqController: def __init__(self, output_sink: str | None) -> None: self.output_backend = PipeWireBackend() - self.output_backend.connect() - self.virtual_sink_name = self.pick_virtual_sink_name() - self.original_default_sink = self.resolve_default_output_sink_name() - self.follow_default_output = output_sink is None - self.output_sink = output_sink or self.original_default_sink - self._output_preset_target_sink: str | None = None - self._output_preset_target: PipeWireOutputPresetTarget | None = None - self.filter_output_name = f"{self.virtual_sink_name}{FILTER_OUTPUT_SUFFIX}" - self.engine_module = None - self.engine_start_watch = None - self.engine_start_pending = False - self.filter_node_id: int | None = None - self.output_event_source_id = 0 - self.pending_followed_output_sink: str | None = None - self.pending_current_output_sink_refresh = False - self.output_object_added_handler_id = 0 - self.output_object_removed_handler_id = 0 - self.output_metadata_changed_handler_id = 0 - self.output_route_param_handler_id = 0 - self.output_route_param_device_id = 0 - self.filter_node_state_handler_id = 0 - self.filter_control_param_handler_id = 0 - self.filter_control_reapply_source_id = 0 - self.filter_control_reapply_source_is_verification = False - self.applying_filter_control_verification = False - self.ignored_filter_control_param_events = 0 - self.ignored_filter_control_param_events_to_verify = 0 - self.ignored_filter_control_param_events_deadline_us = 0 - self.accept_output_events = False - self.routed = False - self.running = False - self.shutting_down = False - self.status_callback: Callable[[str], None] | None = None - self.outputs_changed_callback: Callable[[], None] | None = None - self.analyzer_levels_callback: Callable[[list[float]], None] | None = None - self.analyzer_loudness_callback: Callable[[AnalyzerLoudnessSnapshot | None], None] | None = None - self.eq_enabled = True - self.eq_mode = next(iter(EQ_MODES.values())) - self.preamp_db = 0.0 - self.default_bands: list[EqBand] = self.build_default_bands() - self.bands: list[EqBand] = [replace(band) for band in self.default_bands] - self.stream_router: PipeWireStreamRouter | None = None - self.output_analyzer: OutputSpectrumAnalyzer | None = None - self.analyzer_response_speed = ANALYZER_RESPONSE_DEFAULT - - if not self.is_valid_output_sink(self.output_sink): - raise AudioBackendError("output sink cannot be a Mini EQ virtual sink") - - if not self.output_sink or self.get_sink(self.output_sink) is None: - raise AudioBackendError(f"output sink not found: {self.output_sink}") + try: + self.output_backend.connect() + self.virtual_sink_name = self.pick_virtual_sink_name() + self.original_default_sink = self.resolve_default_output_sink_name() + self.follow_default_output = output_sink is None + self.output_sink = output_sink or self.original_default_sink + self._output_preset_target_sink: str | None = None + self._output_preset_target: PipeWireOutputPresetTarget | None = None + self.filter_output_name = f"{self.virtual_sink_name}{FILTER_OUTPUT_SUFFIX}" + self.engine_module = None + self.engine_start_watch = None + self.engine_start_pending = False + self.filter_node_id: int | None = None + self.output_event_source_id = 0 + self.pending_followed_output_sink: str | None = None + self.pending_current_output_sink_refresh = False + self.output_object_added_handler_id = 0 + self.output_object_removed_handler_id = 0 + self.output_metadata_changed_handler_id = 0 + self.output_route_param_handler_id = 0 + self.output_route_param_device_id = 0 + self.filter_node_state_handler_id = 0 + self.filter_control_param_handler_id = 0 + self.filter_control_reapply_source_id = 0 + self.filter_control_reapply_source_is_verification = False + self.applying_filter_control_verification = False + self.ignored_filter_control_param_events = 0 + self.ignored_filter_control_param_events_to_verify = 0 + self.ignored_filter_control_param_events_deadline_us = 0 + self.accept_output_events = False + self.routed = False + self.running = False + self.shutting_down = False + self.status_callback: Callable[[str], None] | None = None + self.outputs_changed_callback: Callable[[], None] | None = None + self.analyzer_levels_callback: Callable[[list[float]], None] | None = None + self.analyzer_loudness_callback: Callable[[AnalyzerLoudnessSnapshot | None], None] | None = None + self.eq_enabled = True + self.eq_mode = next(iter(EQ_MODES.values())) + self.preamp_db = 0.0 + self.default_bands: list[EqBand] = self.build_default_bands() + self.bands: list[EqBand] = [replace(band) for band in self.default_bands] + self.stream_router: PipeWireStreamRouter | None = None + self.output_analyzer: OutputSpectrumAnalyzer | None = None + self.analyzer_response_speed = ANALYZER_RESPONSE_DEFAULT + + if not self.is_valid_output_sink(self.output_sink): + raise AudioBackendError("output sink cannot be a Mini EQ virtual sink") + + if not self.output_sink or self.get_sink(self.output_sink) is None: + raise AudioBackendError(f"output sink not found: {self.output_sink}") + except Exception: + self.output_backend.close() + raise def emit_status(self, message: str) -> None: if getattr(self, "shutting_down", False): diff --git a/src/mini_eq/window.py b/src/mini_eq/window.py index b21c8b2..a7cf589 100644 --- a/src/mini_eq/window.py +++ b/src/mini_eq/window.py @@ -32,12 +32,13 @@ EQ_MODES, MODE_ORDER, SAMPLE_RATE, + AudioBackendError, ensure_preset_storage_dir, estimate_response_peak_db, ) from .glib_utils import destroy_glib_source from .gtk_utils import create_dropdown_from_strings -from .pipewire_backend import PipeWireNode, node_sample_rate, parse_positive_int +from .pipewire_backend import PipeWireBackendError, PipeWireNode, node_sample_rate, parse_positive_int from .routing import SystemWideEqController from .settings import load_monitor_enabled from .window_analyzer import MiniEqWindowAnalyzerMixin @@ -58,6 +59,8 @@ DEFAULT_WINDOW_HEIGHT = 720 DEFAULT_WINDOW_SCREEN_MARGIN = 32 ROUTING_CLOSE_SETTLE_MS = 300 +STARTUP_AUTO_ROUTE_RETRY_INTERVAL_SECONDS = 1 +STARTUP_AUTO_ROUTE_RETRY_TIMEOUT_US = 30_000_000 TOAST_IGNORED_PREFIXES = ( "filter-chain PipeWire EQ ready:", "filter-chain PipeWire EQ stopped", @@ -185,6 +188,7 @@ def __init__( self.auto_route_on_startup = auto_route self.startup_ready_source_id = 0 self.startup_auto_route_source_id = 0 + self.startup_auto_route_deadline_us = 0 self.startup_ready = False self.present_when_ready = True self.responsive_layout_source_id = 0 @@ -369,18 +373,52 @@ def on_startup_ready_idle(self) -> bool: self.notify_control_state_changed() return False - def schedule_startup_auto_route(self) -> None: + def schedule_startup_auto_route(self, *, retry: bool = False) -> None: if self.startup_auto_route_source_id != 0: return + if retry: + self.startup_auto_route_source_id = GLib.timeout_add_seconds( + STARTUP_AUTO_ROUTE_RETRY_INTERVAL_SECONDS, + self.on_startup_auto_route_idle, + ) + return + self.startup_auto_route_source_id = GLib.idle_add(self.on_startup_auto_route_idle) + def is_startup_auto_route_retryable_error(self, exc: Exception) -> bool: + if isinstance(exc, PipeWireBackendError | AudioBackendError): + return True + + return str(exc) == "filter-chain PipeWire EQ is not ready" + + def schedule_startup_auto_route_retry_after_error(self, exc: Exception) -> bool: + if ( + self.ui_shutting_down + or not self.auto_route_on_startup + or not self.is_startup_auto_route_retryable_error(exc) + ): + return False + + deadline_us = getattr(self, "startup_auto_route_deadline_us", 0) + if deadline_us <= 0: + deadline_us = GLib.get_monotonic_time() + STARTUP_AUTO_ROUTE_RETRY_TIMEOUT_US + self.startup_auto_route_deadline_us = deadline_us + if GLib.get_monotonic_time() >= deadline_us: + return False + + self.schedule_startup_auto_route(retry=True) + return True + def apply_startup_auto_route(self) -> None: eq_was_enabled = self.controller.eq_enabled try: self.controller.route_system_audio(True) except Exception as exc: - self.set_status(str(exc)) + if not self.schedule_startup_auto_route_retry_after_error(exc): + self.set_status(str(exc)) + else: + self.startup_auto_route_deadline_us = 0 self.refresh_after_route_state_changed(eq_was_enabled=eq_was_enabled) def on_startup_auto_route_idle(self) -> bool: diff --git a/tests/test_check_live_ui_runtime.py b/tests/test_check_live_ui_runtime.py index 2d5d239..980bc17 100644 --- a/tests/test_check_live_ui_runtime.py +++ b/tests/test_check_live_ui_runtime.py @@ -57,3 +57,18 @@ def test_live_ui_runtime_rejects_inactive_processing_path(monkeypatch) -> None: ) assert check_live_ui_runtime.processing_path_has_active_links() is False + + +def test_live_ui_runtime_can_skip_static_sink_config(tmp_path) -> None: + check_live_ui_runtime.write_pipewire_config(tmp_path, include_static_sinks=False) + + assert not (tmp_path / "pipewire" / "pipewire.conf.d" / "10-mini-eq-live-ui-null-sinks.conf").exists() + + +def test_live_ui_runtime_dynamic_sink_properties_create_null_audio_sink() -> None: + properties = check_live_ui_runtime.dynamic_sink_properties("ci_null_sink", "CI Null Sink") + + assert "factory.name = support.null-audio-sink" in properties + assert 'node.name = "ci_null_sink"' in properties + assert 'node.description = "CI Null Sink"' in properties + assert 'media.class = "Audio/Sink"' in properties diff --git a/tests/test_mini_eq_app.py b/tests/test_mini_eq_app.py index 030318a..80a1c91 100644 --- a/tests/test_mini_eq_app.py +++ b/tests/test_mini_eq_app.py @@ -1,7 +1,9 @@ from __future__ import annotations from importlib.resources import files -from types import SimpleNamespace +from types import MethodType, SimpleNamespace + +import pytest from tests._mini_eq_imports import import_mini_eq_module @@ -356,6 +358,313 @@ def test_start_active_at_login_can_be_saved_when_start_at_login_is_enabled(monke assert calls == ["state"] +def bind_window_start_methods(application: SimpleNamespace) -> None: + for name in ( + "begin_window_start", + "release_window_start_hold", + "should_retry_startup_auto_route", + "is_startup_auto_route_retryable_error", + "retry_startup_auto_route_after_error", + "on_window_start_retry_timeout", + "fail_window_start", + "raise_window_start_error", + "start_window_controller", + ): + setattr(application, name, MethodType(getattr(app.MiniEqApplication, name), application)) + + +def test_hidden_auto_route_startup_retries_until_output_is_ready(monkeypatch) -> None: + calls: list[object] = [] + scheduled_callbacks = [] + attempts = 0 + + class FakeController: + def __init__(self, output_sink: str | None) -> None: + nonlocal attempts + attempts += 1 + calls.append(("controller", attempts, output_sink)) + if attempts == 1: + raise app.AudioBackendError("output sink not found: ci_null_sink") + + def start(self, *, on_ready=None, on_error=None) -> None: + del on_error + calls.append("start") + on_ready() + + def shutdown(self) -> None: + calls.append("shutdown") + + class FakeMiniEqWindow: + def __init__(self, application, controller, auto_route, initial_curve_label=None) -> None: + del application, controller, initial_curve_label + self.auto_route = auto_route + self.startup_ready = False + self.ui_shutting_down = False + calls.append(("window", auto_route)) + + def set_icon_name(self, icon_name: str) -> None: + calls.append(("icon", icon_name)) + + def set_visible(self, visible: bool) -> None: + calls.append(("visible", visible)) + + def schedule_startup_ready(self) -> None: + calls.append("ready") + + def timeout_add_seconds(interval_seconds: int, callback): + scheduled_callbacks.append(callback) + calls.append(("timeout", interval_seconds)) + return 123 + + monkeypatch.setattr(app, "install_app_icon", lambda: calls.append("icon-install")) + monkeypatch.setattr(app, "SystemWideEqController", FakeController) + monkeypatch.setattr(app, "MiniEqWindow", FakeMiniEqWindow) + monkeypatch.setattr(app.GLib, "get_monotonic_time", lambda: 1_000) + monkeypatch.setattr(app.GLib, "timeout_add_seconds", timeout_add_seconds) + + application = SimpleNamespace( + args=SimpleNamespace(output_sink="ci_null_sink", import_apo=None, background=True, auto_route=True), + controller=None, + window=None, + window_starting=False, + window_start_hold=False, + window_start_retry_source_id=0, + window_start_retry_deadline_us=0, + pending_present_when_ready=False, + hold=lambda: calls.append("hold"), + release=lambda: calls.append("release"), + quit=lambda: calls.append("quit"), + queue_startup_notification_id=lambda _startup_id: None, + update_background_status=lambda: calls.append("background-status"), + emit_control_state_changed=lambda: calls.append("state"), + ) + bind_window_start_methods(application) + + app.MiniEqApplication.ensure_window(application, present=False) + + assert application.window is None + assert application.window_starting is True + assert application.window_start_retry_source_id == 123 + assert calls == [ + "icon-install", + "hold", + ("controller", 1, "ci_null_sink"), + ("timeout", app.STARTUP_AUTO_ROUTE_RETRY_INTERVAL_SECONDS), + ] + + assert scheduled_callbacks[0]() is False + + assert application.window is not None + assert application.window.auto_route is True + assert application.window_starting is False + assert application.window_start_retry_source_id == 0 + assert calls[-9:] == [ + ("controller", 2, "ci_null_sink"), + "start", + ("window", True), + ("icon", app.APP_ICON_NAME), + ("visible", False), + "ready", + "background-status", + "state", + "release", + ] + assert calls[-1] == "release" + assert "quit" not in calls + + +def test_visible_auto_route_startup_retries_until_output_is_ready(monkeypatch) -> None: + calls: list[object] = [] + scheduled_callbacks = [] + attempts = 0 + + class FakeController: + def __init__(self, output_sink: str | None) -> None: + nonlocal attempts + attempts += 1 + calls.append(("controller", attempts, output_sink)) + if attempts == 1: + raise app.PipeWireBackendError("PipeWire core sync failed: PipeWire core sync timed out") + + def start(self, *, on_ready=None, on_error=None) -> None: + del on_error + calls.append("start") + on_ready() + + def shutdown(self) -> None: + calls.append("shutdown") + + class FakeMiniEqWindow: + def __init__(self, application, controller, auto_route, initial_curve_label=None) -> None: + del application, controller, initial_curve_label + self.auto_route = auto_route + self.startup_ready = False + self.ui_shutting_down = False + calls.append(("window", auto_route)) + + def set_icon_name(self, icon_name: str) -> None: + calls.append(("icon", icon_name)) + + def set_visible(self, visible: bool) -> None: + calls.append(("visible", visible)) + + def schedule_startup_ready(self) -> None: + calls.append("ready") + + def timeout_add_seconds(interval_seconds: int, callback): + scheduled_callbacks.append(callback) + calls.append(("timeout", interval_seconds)) + return 123 + + monkeypatch.setattr(app, "install_app_icon", lambda: calls.append("icon-install")) + monkeypatch.setattr(app, "SystemWideEqController", FakeController) + monkeypatch.setattr(app, "MiniEqWindow", FakeMiniEqWindow) + monkeypatch.setattr(app.GLib, "get_monotonic_time", lambda: 1_000) + monkeypatch.setattr(app.GLib, "timeout_add_seconds", timeout_add_seconds) + + application = SimpleNamespace( + args=SimpleNamespace(output_sink=None, import_apo=None, background=False, auto_route=True), + controller=None, + window=None, + window_starting=False, + window_start_hold=False, + window_start_retry_source_id=0, + window_start_retry_deadline_us=0, + pending_present_when_ready=False, + hold=lambda: calls.append("hold"), + release=lambda: calls.append("release"), + quit=lambda: calls.append("quit"), + queue_startup_notification_id=lambda _startup_id: None, + update_background_status=lambda: calls.append("background-status"), + emit_control_state_changed=lambda: calls.append("state"), + ) + bind_window_start_methods(application) + + app.MiniEqApplication.ensure_window(application, present=True) + + assert application.window is None + assert application.window_starting is True + assert application.window_start_retry_source_id == 123 + assert calls == [ + "icon-install", + "hold", + ("controller", 1, None), + ("timeout", app.STARTUP_AUTO_ROUTE_RETRY_INTERVAL_SECONDS), + ] + + assert scheduled_callbacks[0]() is False + + assert application.window is not None + assert application.window.auto_route is True + assert application.window_starting is False + assert application.window_start_retry_source_id == 0 + assert calls[-7:] == [ + ("controller", 2, None), + "start", + ("window", True), + ("icon", app.APP_ICON_NAME), + ("visible", False), + "ready", + "release", + ] + assert "quit" not in calls + + +def test_visible_startup_constructor_error_raises_system_exit(monkeypatch) -> None: + calls: list[str] = [] + + class FakeController: + def __init__(self, output_sink: str | None) -> None: + calls.append(f"controller:{output_sink}") + raise RuntimeError("output sink not found: missing") + + monkeypatch.setattr(app, "install_app_icon", lambda: calls.append("icon-install")) + monkeypatch.setattr(app, "SystemWideEqController", FakeController) + + application = SimpleNamespace( + args=SimpleNamespace(output_sink="missing", import_apo=None, background=False, auto_route=False), + controller=None, + window=None, + window_starting=False, + window_start_hold=False, + window_start_retry_source_id=0, + window_start_retry_deadline_us=0, + pending_present_when_ready=False, + hold=lambda: calls.append("hold"), + release=lambda: calls.append("release"), + quit=lambda: calls.append("quit"), + queue_startup_notification_id=lambda _startup_id: None, + ) + bind_window_start_methods(application) + + with pytest.raises(SystemExit, match="output sink not found: missing"): + app.MiniEqApplication.ensure_window(application, present=True) + + assert application.window is None + assert application.window_starting is False + assert application.pending_present_when_ready is False + assert calls == ["icon-install", "hold", "controller:missing", "release"] + + +def test_auto_route_import_error_does_not_retry(monkeypatch) -> None: + calls: list[object] = [] + + class FakeController: + def __init__(self, output_sink: str | None) -> None: + calls.append(("controller", output_sink)) + + def import_apo_preset(self, path: str) -> int: + calls.append(("import", path)) + raise ValueError("invalid APO preset") + + def shutdown(self) -> None: + calls.append("shutdown") + + def timeout_add_seconds(_interval_seconds: int, _callback): + raise AssertionError("permanent startup errors should not schedule a retry") + + monkeypatch.setattr(app, "install_app_icon", lambda: calls.append("icon-install")) + monkeypatch.setattr(app, "SystemWideEqController", FakeController) + monkeypatch.setattr(app.GLib, "get_monotonic_time", lambda: 1_000) + monkeypatch.setattr(app.GLib, "timeout_add_seconds", timeout_add_seconds) + + application = SimpleNamespace( + args=SimpleNamespace( + output_sink="ci_null_sink", + import_apo="/tmp/broken.txt", + background=False, + auto_route=True, + ), + controller=None, + window=None, + window_starting=False, + window_start_hold=False, + window_start_retry_source_id=0, + window_start_retry_deadline_us=0, + pending_present_when_ready=False, + hold=lambda: calls.append("hold"), + release=lambda: calls.append("release"), + quit=lambda: calls.append("quit"), + queue_startup_notification_id=lambda _startup_id: None, + ) + bind_window_start_methods(application) + + with pytest.raises(SystemExit, match="invalid APO preset"): + app.MiniEqApplication.ensure_window(application, present=True) + + assert application.window_starting is False + assert application.window_start_retry_source_id == 0 + assert application.pending_present_when_ready is False + assert calls == [ + "icon-install", + "hold", + ("controller", "ci_null_sink"), + ("import", "/tmp/broken.txt"), + "shutdown", + "release", + ] + + def test_second_normal_launch_presents_running_instance(monkeypatch, capsys) -> None: calls: list[str] = [] diff --git a/tests/test_mini_eq_routing.py b/tests/test_mini_eq_routing.py index e17d719..1d2817b 100644 --- a/tests/test_mini_eq_routing.py +++ b/tests/test_mini_eq_routing.py @@ -64,6 +64,31 @@ def refresh_defaults(self, *, snapshot: bool = False) -> pw_backend.PipeWireDefa return self.refreshed_defaults +def test_constructor_closes_backend_when_output_sink_validation_fails(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + class FakeBackend(FakeDefaultOutputBackend): + def __init__(self) -> None: + super().__init__( + [make_node(1, "speakers")], + cached_defaults=pw_backend.PipeWireDefaults("speakers", None), + refreshed_defaults=pw_backend.PipeWireDefaults("speakers", None), + ) + + def connect(self) -> None: + calls.append("connect") + + def close(self) -> None: + calls.append("close") + + monkeypatch.setattr(routing, "PipeWireBackend", FakeBackend) + + with pytest.raises(core.AudioBackendError, match="output sink not found: missing"): + routing.SystemWideEqController("missing") + + assert calls == ["connect", "close"] + + def test_list_output_sink_names_uses_wireplumber_sinks_and_filters_internal_nodes() -> None: controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) controller.output_backend = FakeOutputBackend( diff --git a/tests/test_mini_eq_window.py b/tests/test_mini_eq_window.py index d41bff7..5c02aa2 100644 --- a/tests/test_mini_eq_window.py +++ b/tests/test_mini_eq_window.py @@ -99,10 +99,26 @@ def bind_control_refresh_methods(fake_window: SimpleNamespace) -> None: window.MiniEqWindow.refresh_after_eq_state_changed, fake_window, ) + fake_window.schedule_startup_auto_route = MethodType( + window.MiniEqWindow.schedule_startup_auto_route, + fake_window, + ) + fake_window.is_startup_auto_route_retryable_error = MethodType( + window.MiniEqWindow.is_startup_auto_route_retryable_error, + fake_window, + ) + fake_window.schedule_startup_auto_route_retry_after_error = MethodType( + window.MiniEqWindow.schedule_startup_auto_route_retry_after_error, + fake_window, + ) fake_window.apply_startup_auto_route = MethodType( window.MiniEqWindow.apply_startup_auto_route, fake_window, ) + fake_window.on_startup_auto_route_idle = MethodType( + window.MiniEqWindow.on_startup_auto_route_idle, + fake_window, + ) def test_about_dialog_includes_current_release_notes(monkeypatch) -> None: @@ -472,6 +488,76 @@ def route_system_audio(enabled: bool) -> None: ] +def test_startup_auto_route_retries_until_filter_chain_is_ready(monkeypatch) -> None: + calls: list[object] = [] + scheduled_callbacks = [] + controller = SimpleNamespace(eq_enabled=True, routed=False) + attempts = 0 + + def route_system_audio(enabled: bool) -> None: + nonlocal attempts + attempts += 1 + calls.append(("route", attempts, enabled)) + if attempts == 1: + raise RuntimeError("filter-chain PipeWire EQ is not ready") + controller.routed = enabled + + def timeout_add_seconds(interval_seconds: int, callback): + scheduled_callbacks.append(callback) + calls.append(("timeout", interval_seconds)) + return 777 + + controller.route_system_audio = route_system_audio + monkeypatch.setattr(window.GLib, "get_monotonic_time", lambda: 1_000) + monkeypatch.setattr(window.GLib, "timeout_add_seconds", timeout_add_seconds) + + fake_window = SimpleNamespace( + startup_auto_route_source_id=0, + startup_auto_route_deadline_us=0, + ui_shutting_down=False, + auto_route_on_startup=True, + updating_ui=False, + route_switch=FakeSwitch(False), + bypass_switch=FakeSwitch(True), + controller=controller, + update_eq_power_indicator=lambda: calls.append(("power", fake_window.route_switch.get_active())), + update_info_label=lambda: calls.append(("info", fake_window.route_switch.get_active())), + update_status_summary=lambda: calls.append(("summary", fake_window.route_switch.get_active())), + update_focus_summary=lambda: calls.append("focus"), + set_status=lambda message: calls.append(("status", message)), + notify_control_state_changed=lambda: calls.append("notify"), + ) + bind_control_refresh_methods(fake_window) + + window.MiniEqWindow.apply_startup_auto_route(fake_window) + + assert fake_window.startup_auto_route_source_id == 777 + assert fake_window.route_switch.get_active() is False + assert ("status", "filter-chain PipeWire EQ is not ready") not in calls + + assert scheduled_callbacks[0]() is False + + assert fake_window.startup_auto_route_source_id == 0 + assert fake_window.startup_auto_route_deadline_us == 0 + assert fake_window.route_switch.get_active() is True + assert fake_window.route_switch.get_state() is True + assert calls == [ + ("route", 1, True), + ("timeout", window.STARTUP_AUTO_ROUTE_RETRY_INTERVAL_SECONDS), + ("power", False), + ("info", False), + ("summary", False), + "focus", + "notify", + ("route", 2, True), + ("power", True), + ("info", True), + ("summary", True), + "focus", + "notify", + ] + + def test_status_summary_uses_controller_route_state_over_stale_switch() -> None: headroom_states: list[dict[str, object]] = [] state_label = FakeStateLabel() diff --git a/tools/check_live_ui_runtime.py b/tools/check_live_ui_runtime.py index 4eada15..3ef4d6f 100755 --- a/tools/check_live_ui_runtime.py +++ b/tools/check_live_ui_runtime.py @@ -327,7 +327,10 @@ def write_settings(config_dir: Path) -> None: ) -def write_pipewire_config(config_dir: Path) -> None: +def write_pipewire_config(config_dir: Path, *, include_static_sinks: bool = True) -> None: + if not include_static_sinks: + return + conf_dir = config_dir / "pipewire" / "pipewire.conf.d" conf_dir.mkdir(parents=True, exist_ok=True) (conf_dir / "10-mini-eq-live-ui-null-sinks.conf").write_text( @@ -368,6 +371,31 @@ def write_pipewire_config(config_dir: Path) -> None: ) +def dynamic_sink_properties(sink_name: str, description: str) -> str: + return ( + "{ " + "factory.name = support.null-audio-sink " + f'node.name = "{sink_name}" ' + f'node.description = "{description}" ' + 'media.class = "Audio/Sink" ' + "object.linger = true " + 'audio.position = "FL,FR" ' + "adapter.auto-port-config = { " + "mode = dsp " + "monitor = true " + "position = preserve " + "} " + "}" + ) + + +def create_dynamic_sink(sink_name: str, description: str, timeout_seconds: float) -> dict[str, Any]: + command = ["pw-cli", "create-node", "adapter", dynamic_sink_properties(sink_name, description)] + print(f"$ {format_command(command)}", flush=True) + subprocess.run(command, check=True, text=True, stdout=subprocess.DEVNULL) + return wait_for(sink_name, lambda: node_by_name(sink_name), timeout_seconds) + + def create_sine_wav(path: Path, duration_seconds: float) -> Path: sample_rate = 48_000 frame_count = max(1, int(duration_seconds * sample_rate)) @@ -906,7 +934,12 @@ def start_nested_shell(runtime_dir: Path, wayland_name: str, log_path: Path) -> return process -def start_app(repo_root: Path, wayland_name: str, app_log_path: Path) -> subprocess.Popen[str]: +def start_app( + repo_root: Path, + wayland_name: str, + app_log_path: Path, + app_args: list[str] | None = None, +) -> subprocess.Popen[str]: env = os.environ.copy() src_path = str(repo_root / "src") env["PYTHONPATH"] = f"{src_path}{os.pathsep}{env['PYTHONPATH']}" if env.get("PYTHONPATH") else src_path @@ -917,7 +950,7 @@ def start_app(repo_root: Path, wayland_name: str, app_log_path: Path) -> subproc env.pop("DISPLAY", None) app_log = app_log_path.open("w", encoding="utf-8") process = subprocess.Popen( - [sys.executable, "-m", "mini_eq", "--auto-route"], + [sys.executable, "-m", "mini_eq", *(app_args if app_args is not None else ["--auto-route"])], cwd=repo_root, env=env, stdout=app_log, @@ -1316,6 +1349,111 @@ def run_ui_flow( terminate_process(shell, "nested GNOME Shell") +def run_startup_routing_race_simulation( + *, + pyatspi, + repo_root: Path, + runtime_dir: Path, + tmp_dir: Path, + timeout_seconds: float, + audio_duration: float, +) -> None: + shell_log_path = tmp_dir / "gnome-shell.log" + autostart_log_path = tmp_dir / "mini-eq-autostart.log" + present_log_path = tmp_dir / "mini-eq-present.log" + wayland_name = f"mini-eq-startup-race-{os.getpid()}" + shell: subprocess.Popen[str] | None = None + autostart_app: subprocess.Popen[str] | None = None + smoke: subprocess.Popen[str] | None = None + event_thread: threading.Thread | None = None + + try: + wait_for("PipeWire default metadata service", default_metadata_is_ready, timeout_seconds) + shell = start_nested_shell(runtime_dir, wayland_name, shell_log_path) + + autostart_app = start_app( + repo_root, + wayland_name, + autostart_log_path, + app_args=["--background", "--auto-route", "--output-sink", PRIMARY_SINK_NAME], + ) + try: + autostart_app.wait(timeout=2.0) + except subprocess.TimeoutExpired: + pass + else: + raise AssertionError( + "Mini EQ startup-race autostart exited before the sink appeared:\n" + f"returncode={autostart_app.returncode}\n{autostart_log_path.read_text(errors='replace')}" + ) + + create_dynamic_sink(PRIMARY_SINK_NAME, "CI Null Sink", timeout_seconds) + create_dynamic_sink(ALT_SINK_NAME, "CI Alt Sink", timeout_seconds) + + wait_for( + "Mini EQ hidden autostart to recover and route after output appears", + lambda: state if (state := control_state(timeout_seconds)).get("routed") is True else None, + timeout_seconds, + ) + + audio_file = create_sine_wav(tmp_dir / "mini-eq-startup-race-smoke.wav", audio_duration) + smoke = start_smoke_stream(audio_file) + smoke_node = wait_for( + "synthetic PipeWire playback stream after startup-race recovery", + lambda: smoke_stream_node() if smoke is not None and smoke.poll() is None else None, + timeout_seconds, + ) + smoke_id = node_id(smoke_node) + + present_process = start_app(repo_root, wayland_name, present_log_path, app_args=[]) + try: + present_process.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired as exc: + terminate_process(present_process, "Mini EQ startup-race present request") + raise AssertionError("Mini EQ present request did not return after startup-race recovery") from exc + if present_process.returncode != 0: + raise AssertionError( + "Mini EQ present request failed after startup-race recovery:\n" + f"returncode={present_process.returncode}\n{present_log_path.read_text(errors='replace')}" + ) + + event_thread = start_accessible_event_loop(pyatspi) + driver = UiDriver(pyatspi, autostart_app, autostart_log_path, shell_log_path) + + frame = driver.wait_for_accessible( + "Mini EQ frame after startup-race recovery", + lambda: driver.find(driver.desktop(), name=APP_FRAME_NAME, role="frame", showing=True), + timeout_seconds, + ) + route_switch = driver.wait_for_accessible( + "System-wide EQ switch to turn on after startup-race recovery", + lambda: driver.visible_switch_with_state(frame, name="System-wide EQ", expected_checked=True), + timeout_seconds, + ) + del route_switch + state = control_state(timeout_seconds) + if state.get("routed") is not True: + raise AssertionError(f"Mini EQ D-Bus state should be routed after startup-race recovery: {state!r}") + + virtual_sink = wait_for_sink(VIRTUAL_SINK_NAME, timeout_seconds) + virtual_serial = object_serial(virtual_sink) + wait_for_route_to_virtual(smoke_id, virtual_serial, timeout_seconds) + wait_for_processing_path_active(timeout_seconds) + if not no_traceback(autostart_log_path): + raise AssertionError(f"Mini EQ logged a traceback:\n{autostart_log_path.read_text(errors='replace')}") + + print( + "Live UI startup-race simulation passed: " + "the hidden --auto-route launch stayed alive before the requested sink existed, " + "then recovered and routed system audio after the sink appeared." + ) + finally: + stop_accessible_event_loop(pyatspi, event_thread) + terminate_process(autostart_app, "Mini EQ startup-race autostart") + terminate_process(smoke, "pw-cat synthetic stream") + terminate_process(shell, "nested GNOME Shell") + + def start_pipewire_processes(tmp_dir: Path) -> tuple[subprocess.Popen[str], subprocess.Popen[str]]: pipewire_log = (tmp_dir / "pipewire.log").open("w", encoding="utf-8") wireplumber_log = (tmp_dir / "wireplumber.log").open("w", encoding="utf-8") @@ -1333,8 +1471,13 @@ def run_helper(_args: argparse.Namespace) -> int: print(f"pyatspi unavailable: {exc}", file=sys.stderr) return HELPER_SKIP_EXIT_CODE + simulate_startup_race = os.environ.get("MINI_EQ_LIVE_UI_SIMULATE_STARTUP_ROUTING_RACE") == "1" + required_tools = ["pipewire", "wireplumber", "wpctl", "pw-cat", "pw-dump", "pw-metadata", "gnome-shell"] + if simulate_startup_race: + required_tools.append("pw-cli") + try: - for tool in ("pipewire", "wireplumber", "wpctl", "pw-cat", "pw-dump", "pw-metadata", "gnome-shell"): + for tool in required_tools: require_tool(tool) except RuntimeError as exc: print(str(exc), file=sys.stderr) @@ -1356,7 +1499,7 @@ def run_helper(_args: argparse.Namespace) -> int: directory.mkdir(parents=True, exist_ok=True) runtime_dir.chmod(0o700) write_settings(config_dir) - write_pipewire_config(config_dir) + write_pipewire_config(config_dir, include_static_sinks=not simulate_startup_race) os.environ["XDG_RUNTIME_DIR"] = str(runtime_dir) os.environ["XDG_CONFIG_HOME"] = str(config_dir) @@ -1365,18 +1508,28 @@ def run_helper(_args: argparse.Namespace) -> int: os.environ["GSETTINGS_BACKEND"] = "memory" pipewire, wireplumber = start_pipewire_processes(tmp_dir) - wait_for_sink(PRIMARY_SINK_NAME, timeout_seconds) - wait_for_sink(ALT_SINK_NAME, timeout_seconds) - wait_for("WirePlumber default output metadata", default_output_metadata_is_ready, timeout_seconds) - run_ui_flow( - pyatspi=pyatspi, - repo_root=REPO_ROOT, - runtime_dir=runtime_dir, - tmp_dir=tmp_dir, - timeout_seconds=timeout_seconds, - cycles=cycles, - audio_duration=audio_duration, - ) + if simulate_startup_race: + run_startup_routing_race_simulation( + pyatspi=pyatspi, + repo_root=REPO_ROOT, + runtime_dir=runtime_dir, + tmp_dir=tmp_dir, + timeout_seconds=timeout_seconds, + audio_duration=audio_duration, + ) + else: + wait_for_sink(PRIMARY_SINK_NAME, timeout_seconds) + wait_for_sink(ALT_SINK_NAME, timeout_seconds) + wait_for("WirePlumber default output metadata", default_output_metadata_is_ready, timeout_seconds) + run_ui_flow( + pyatspi=pyatspi, + repo_root=REPO_ROOT, + runtime_dir=runtime_dir, + tmp_dir=tmp_dir, + timeout_seconds=timeout_seconds, + cycles=cycles, + audio_duration=audio_duration, + ) finally: terminate_process(wireplumber, "WirePlumber") terminate_process(pipewire, "PipeWire") @@ -1398,6 +1551,7 @@ def run_parent(args: argparse.Namespace) -> int: env["MINI_EQ_LIVE_UI_TIMEOUT"] = str(args.timeout) env["MINI_EQ_LIVE_UI_CYCLES"] = str(args.cycles) env["MINI_EQ_LIVE_UI_AUDIO_DURATION"] = str(args.audio_duration) + env["MINI_EQ_LIVE_UI_SIMULATE_STARTUP_ROUTING_RACE"] = "1" if args.simulate_startup_routing_race else "0" env["PYTHONUNBUFFERED"] = "1" env.pop("DISPLAY", None) env.pop("WAYLAND_DISPLAY", None) @@ -1434,6 +1588,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace: default=120.0, help="Duration of the generated sine-wave playback stream.", ) + parser.add_argument( + "--simulate-startup-routing-race", + action="store_true", + help="Launch hidden --auto-route before the requested output sink exists, then verify routed recovery.", + ) return parser.parse_args(argv)