fix(ibus): keep engine teardown correct when parent destroy fails (#613)
* fix(ibus): keep engine teardown correct when parent destroy fails
`do_destroy` passed `super().do_destroy` to `_handle_engine_destroy`.
PyGObject binds that vfunc to the GType rather than to the instance, so
calling it with no arguments raises:
TypeError: IBus.Object.destroy() takes exactly 1 argument (0 given)
This fired on every engine teardown. The exception escaped
`_handle_engine_destroy` before its return, so neither
`_active_instance = ...` nor `_focus_event.clear()` in the caller ran,
leaving a destroyed engine registered as active with focus still set --
the two values `_ibus_ping_status` (#523) reads to decide commit
readiness.
Two changes:
- Bind the parent destroy to the instance, falling back to
`IBus.Object.destroy(self)` only when the no-arg call raises TypeError,
so this keeps working if PyGObject binds the vfunc correctly later.
- Guard the parent destroy call so no failure there can skip teardown.
Fixes #606
* refactor(ibus): extract parent-destroy workaround into a testable helper
Codecov flagged the new code as uncovered (36% patch, below the 80%
target). The whole `if IBUS_AVAILABLE:` branch of `do_destroy` is
unreachable from the suite, which only ever exercises that method with
IBUS_AVAILABLE False -- the single line it replaced was already
uncovered on main for the same reason.
Move the workaround into a module-level `_invoke_parent_destroy`, the
same pattern `_handle_engine_destroy` already uses to make `do_destroy`
testable, and bind it with functools.partial at the call site.
Behaviour is unchanged. Coverage of the branch goes from 0 to all but
the one-line partial, and three tests now pin the actual contract:
pass-through when the binding is correct, retry with the instance on the
GType-binding TypeError, and no masking of other exceptions.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user