diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index ad0f173..b01087a 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -14,6 +14,7 @@ This is the preferred method for Wayland environments as it works universally without requiring compositor-specific protocols. """ +import functools import logging import os import signal @@ -589,6 +590,23 @@ def restore_xkb_layout(layout: str, variant: str = "", option: str = "") -> bool return False +def _invoke_parent_destroy(parent_destroy: Callable[[], None], instance: object) -> None: + """Call the parent engine destroy, working around PyGObject's GType binding. + + PyGObject binds the ``do_destroy`` vfunc to the GType rather than to the + instance, so ``super().do_destroy()`` raises + ``TypeError: IBus.Object.destroy() takes exactly 1 argument (0 given)``. + Retry with the instance explicitly. + + The TypeError path rather than an unconditional ``IBus.Object.destroy`` + keeps this working if a future PyGObject binds the vfunc correctly. + """ + try: + parent_destroy() + except TypeError: + IBus.Object.destroy(instance) + + def _handle_engine_destroy( active_instance: Optional["VocalinuxEngine"], current_instance: object, @@ -601,7 +619,13 @@ def _handle_engine_destroy( next_active_instance = None if ibus_available and super_destroy is not None: - super_destroy() + # The caller assigns our return value to _active_instance and clears the + # focus event, so a failing parent destroy must not skip that teardown: + # otherwise a destroyed engine stays registered as active with focus set. + try: + super_destroy() + except Exception: + logger.exception("Parent engine destroy failed; continuing teardown") return next_active_instance @@ -866,7 +890,7 @@ class VocalinuxEngine(IBus.Engine if IBUS_AVAILABLE else object): logger.debug("VocalinuxEngine instance destroyed") super_destroy: Optional[Callable[[], None]] = None if IBUS_AVAILABLE: - super_destroy = super().do_destroy + super_destroy = functools.partial(_invoke_parent_destroy, super().do_destroy, self) was_active = VocalinuxEngine._active_instance is self VocalinuxEngine._active_instance = _handle_engine_destroy( VocalinuxEngine._active_instance, diff --git a/tests/test_ibus_engine.py b/tests/test_ibus_engine.py index d03b3f7..44c8820 100644 --- a/tests/test_ibus_engine.py +++ b/tests/test_ibus_engine.py @@ -2015,6 +2015,88 @@ class TestVocalinuxEngineDestroy(unittest.TestCase): mock_super_destroy.assert_not_called() + def test_invoke_parent_destroy_passes_through_when_binding_is_correct(self): + """A parent destroy that accepts no arguments is called as-is (#606).""" + from vocalinux.text_injection.ibus_engine import _invoke_parent_destroy + + parent_destroy = MagicMock() + + _invoke_parent_destroy(parent_destroy, object()) + + parent_destroy.assert_called_once_with() + + def test_invoke_parent_destroy_retries_with_instance_on_type_error(self): + """PyGObject binds the vfunc to the GType, so retry with the instance (#606). + + ``super().do_destroy`` resolves to a method bound to the GType rather + than the engine, so the no-argument call raises TypeError and the real + destroy never happens. + """ + import vocalinux.text_injection.ibus_engine as mod + from vocalinux.text_injection.ibus_engine import _invoke_parent_destroy + + parent_destroy = MagicMock( + side_effect=TypeError("IBus.Object.destroy() takes exactly 1 argument (0 given)") + ) + instance = object() + fake_ibus = MagicMock() + + with patch.object(mod, "IBus", fake_ibus): + _invoke_parent_destroy(parent_destroy, instance) + + parent_destroy.assert_called_once_with() + fake_ibus.Object.destroy.assert_called_once_with(instance) + + def test_invoke_parent_destroy_propagates_other_errors(self): + """Only the GType-binding TypeError is worked around; nothing else is masked.""" + from vocalinux.text_injection.ibus_engine import _invoke_parent_destroy + + parent_destroy = MagicMock(side_effect=RuntimeError("boom")) + + with self.assertRaises(RuntimeError): + _invoke_parent_destroy(parent_destroy, object()) + + def test_do_destroy_clears_active_instance_when_super_raises(self): + """A failing parent destroy must not skip teardown (#606). + + PyGObject binds the destroy vfunc to the GType rather than the instance, + so ``super().do_destroy()`` raised TypeError on every teardown. That + exception escaped before the return value was assigned, leaving a + destroyed engine registered as active with the focus event still set. + """ + from vocalinux.text_injection.ibus_engine import _handle_engine_destroy + + engine = object() + failing_super_destroy = MagicMock( + side_effect=TypeError("IBus.Object.destroy() takes exactly 1 argument (0 given)") + ) + + next_active = _handle_engine_destroy( + active_instance=engine, + current_instance=engine, + ibus_available=True, + super_destroy=failing_super_destroy, + ) + + failing_super_destroy.assert_called_once() + self.assertIsNone(next_active) + + def test_do_destroy_keeps_other_instance_when_super_raises(self): + """A failing parent destroy still returns the unrelated active engine (#606).""" + from vocalinux.text_injection.ibus_engine import _handle_engine_destroy + + active_engine = object() + failing_super_destroy = MagicMock(side_effect=RuntimeError("boom")) + + next_active = _handle_engine_destroy( + active_instance=active_engine, + current_instance=object(), + ibus_available=True, + super_destroy=failing_super_destroy, + ) + + self.assertIs(next_active, active_engine) + def test_do_destroy_handles_missing_super_callback(self): """Test destroy handler works when no parent destroy callback is provided.""" from vocalinux.text_injection.ibus_engine import _handle_engine_destroy