fix(ibus): restore engine after register_component teardown (#558) (#643)

* fix(ibus): restore engine after register_component teardown

Stopping the Vocalinux IBus engine process destroys its
register_component registration. IBus then runs check_global_engine(),
which only searches register_engine_list and treats XML engines like
xkb:es::spa as missing, clearing GlobalEngine even when Vocalinux was
never selected. Capture a restorable engine before teardown and
reselect it afterward so dead keys keep working after quit.

Fixes #558

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

* fix(ibus): wait for engine exit before restoring GlobalEngine

Adversarial review of #643 found that stop_engine_process() returned
right after SIGTERM, so switch_engine() could race ahead of IBus
check_global_engine() and still leave No global engine. Wait for the
process to exit (SIGKILL fallback), retry engine restore, capture a
shutdown fallback during prepare_engine, and re-apply XKB after the
post-teardown engine switch.

Addresses review of #558 / #643.

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

* fix(ibus): prefer live engine over stale warmup on quit

Bugbot on #643: stop() restored the warmup-cached engine even when the
user had switched input source afterward, and inject_text refused to
refresh that cache once set. Prefer the live non-Vocalinux engine at
quit; fall back to the cache only when stuck on vocalinux/unavailable.
Always refresh the shutdown fallback from inject_text.

Also cover SIGKILL teardown escalation and restore-retry failure paths
to lift patch coverage.

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
This commit is contained in:
Jatin K Malik
2026-08-05 21:41:10 -07:00
committed by GitHub
co-authored by Jatin K Malik Cursor Agent
parent 45acb10d13
commit 7a5ddfde38
4 changed files with 346 additions and 28 deletions
+82 -12
View File
@@ -839,8 +839,13 @@ def start_engine_process() -> bool:
return False
def stop_engine_process() -> None:
"""Stop the IBus engine process if running."""
def stop_engine_process(wait_timeout: float = 2.0) -> None:
"""Stop the IBus engine process if running.
Waits for the process to actually exit so callers that restore the user's
IBus engine afterward run after ``register_component`` teardown has been
observed by ibus-daemon (issue #558).
"""
try:
if not PID_FILE.exists():
logger.debug("No PID file found, engine not running")
@@ -857,6 +862,29 @@ def stop_engine_process() -> None:
return
os.kill(pid, signal.SIGTERM)
# Wait for exit: IBus clears/repairs GlobalEngine on component destroy,
# which only runs after the process actually disconnects (#558).
deadline = time.monotonic() + max(wait_timeout, 0.0)
while time.monotonic() < deadline:
try:
os.kill(pid, 0)
except OSError:
break
time.sleep(0.05)
else:
# Still alive — escalate so teardown (and GlobalEngine repair) finishes.
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
kill_deadline = time.monotonic() + 0.5
while time.monotonic() < kill_deadline:
try:
os.kill(pid, 0)
except OSError:
break
time.sleep(0.05)
logger.info(f"IBus engine process (PID {pid}) stopped")
PID_FILE.unlink()
except (OSError, ValueError, FileNotFoundError) as e:
@@ -1206,7 +1234,18 @@ class IBusTextInjector:
The Vocalinux engine is only needed while committing dictated text. Keeping
the user's normal IBus/XKB engine active preserves dead-key composition and
layout-specific behavior during ordinary typing.
Captures the current engine for shutdown restore only. Injection still
resolves the restore target at commit time so a stale warmup value is not
used mid-session (see #623); shutdown needs a fallback when quit leaves
Vocalinux selected after a failed inject restore (#558).
"""
if not is_engine_active():
captured = get_current_engine()
if captured and captured != ENGINE_NAME:
self._previous_engine = captured
logger.debug(f"Captured current engine for shutdown restore: {captured}")
if not start_engine_process():
raise IBusSetupError("Failed to start IBus engine process. Check logs for details.")
@@ -1303,18 +1342,49 @@ class IBusTextInjector:
captured before injection (e.g. on GNOME/Wayland with no global IBus
engine), restoration is skipped to avoid switching the user to a
wrong layout (see issue #497).
Engine restoration must run *after* ``stop_engine_process()`` has
observed process exit. Killing the process that called
``register_component`` makes IBus run ``check_global_engine()``, which
only searches ``register_engine_list`` and treats XML engines such as
``xkb:es::spa`` as missing, clearing the global engine even when
Vocalinux was never selected (issue #558).
Prefer the *live* engine at quit when it is a real non-Vocalinux
engine, so a user who changed input source after warmup is not
switched back to a stale cache. Fall back to ``_previous_engine`` only
when the live engine is Vocalinux or unavailable (failed inject restore).
"""
if self._previous_engine:
logger.info(f"Restoring previous engine: {self._previous_engine}")
switch_engine(self._previous_engine)
self._previous_engine = None
current = get_current_engine()
if current and current != ENGINE_NAME:
restore_engine = current
else:
restore_engine = self._previous_engine
if restore_engine == ENGINE_NAME:
restore_engine = None
# Stop the engine process first and wait for exit — teardown can clear
# IBus GlobalEngine (#558). Restore must land after that check.
stop_engine_process()
if restore_engine:
logger.info(f"Restoring previous engine after teardown: {restore_engine}")
restored = False
for attempt in range(3):
if switch_engine(restore_engine):
restored = True
break
time.sleep(0.15 * (attempt + 1))
if not restored:
logger.error(f"Failed to restore IBus engine after teardown: {restore_engine}")
else:
logger.info(
"Skipping engine restoration — " "no valid engine was captured before injection"
"Skipping engine restoration — no restorable engine was available at shutdown"
)
self._previous_engine = None
# Restore the XKB layout that was captured during setup
# This ensures the user's original keyboard layout is preserved
# XKB last: switching engines can override layout (#292). Re-apply after
# the post-teardown engine restore so we don't lose the user's map.
if self._previous_xkb_layout:
layout, variant, option = self._previous_xkb_layout
if layout:
@@ -1322,9 +1392,6 @@ class IBusTextInjector:
restore_xkb_layout(layout, variant, option)
self._previous_xkb_layout = None
# Stop the engine process
stop_engine_process()
def inject_text(self, text: str) -> bool:
"""
Inject text using the IBus engine.
@@ -1352,6 +1419,9 @@ class IBusTextInjector:
return False
restore_engine = current_engine
# Always refresh the shutdown fallback to the live engine so a later
# quit does not restore a stale warmup value (Bugbot on #643 / #558).
self._previous_engine = current_engine
logger.debug(
f"Temporarily activating Vocalinux IBus engine (will restore {current_engine})"
)
+176 -14
View File
@@ -658,24 +658,85 @@ class TestIBusTextInjector(unittest.TestCase):
with self.assertRaises(RuntimeError):
IBusTextInjector()
@patch("vocalinux.text_injection.ibus_engine.stop_engine_process")
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value=None)
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
@patch("vocalinux.text_injection.ibus_engine.switch_engine")
def test_stop_restores_engine(self, mock_switch, mock_ensure_dir):
"""Test stop() restores previous engine."""
@patch("vocalinux.text_injection.ibus_engine.switch_engine", return_value=True)
def test_stop_restores_engine(
self, mock_switch, mock_ensure_dir, mock_get_current, mock_stop_proc
):
"""Test stop() restores cached engine when live engine is unavailable (#558)."""
from vocalinux.text_injection.ibus_engine import IBusTextInjector
parent = MagicMock()
parent.attach_mock(mock_stop_proc, "stop_proc")
parent.attach_mock(mock_switch, "switch")
injector = IBusTextInjector(auto_activate=False)
injector._previous_engine = "xkb:fr::fra"
injector.stop()
mock_stop_proc.assert_called_once()
mock_switch.assert_called_once_with("xkb:fr::fra")
self.assertEqual([c[0] for c in parent.mock_calls], ["stop_proc", "switch"])
self.assertIsNone(injector._previous_engine)
@patch("vocalinux.text_injection.ibus_engine.stop_engine_process")
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="xkb:de::deu")
@patch("vocalinux.text_injection.ibus_engine.switch_engine", return_value=True)
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
def test_stop_no_previous_engine(self, mock_ensure_dir):
def test_stop_prefers_live_engine_over_stale_warmup_cache(
self, mock_ensure_dir, mock_switch, mock_get_current, mock_stop_proc
):
"""Quit must restore the live input source, not a stale warmup cache (#643 Bugbot)."""
from vocalinux.text_injection.ibus_engine import IBusTextInjector
injector = IBusTextInjector(auto_activate=False)
injector._previous_engine = "xkb:es::spa"
injector.stop()
mock_get_current.assert_called()
mock_stop_proc.assert_called_once()
mock_switch.assert_called_once_with("xkb:de::deu")
self.assertIsNone(injector._previous_engine)
@patch("vocalinux.text_injection.ibus_engine.stop_engine_process")
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="xkb:es::spa")
@patch("vocalinux.text_injection.ibus_engine.switch_engine", return_value=True)
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
def test_stop_restores_current_engine_when_previous_unset(
self, mock_ensure_dir, mock_switch, mock_get_current, mock_stop_proc
):
"""Warmup quit must restore the live engine after register_component teardown (#558)."""
from vocalinux.text_injection.ibus_engine import IBusTextInjector
parent = MagicMock()
parent.attach_mock(mock_stop_proc, "stop_proc")
parent.attach_mock(mock_switch, "switch")
injector = IBusTextInjector(auto_activate=False)
self.assertIsNone(injector._previous_engine)
injector.stop()
mock_get_current.assert_called()
mock_stop_proc.assert_called_once()
mock_switch.assert_called_once_with("xkb:es::spa")
self.assertEqual([c[0] for c in parent.mock_calls], ["stop_proc", "switch"])
@patch("vocalinux.text_injection.ibus_engine.stop_engine_process")
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value=None)
@patch("vocalinux.text_injection.ibus_engine.switch_engine")
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
def test_stop_no_previous_engine(
self, mock_ensure_dir, mock_switch, mock_get_current, mock_stop_proc
):
"""Test stop() when no previous engine was saved."""
from vocalinux.text_injection.ibus_engine import IBusTextInjector
@@ -683,33 +744,49 @@ class TestIBusTextInjector(unittest.TestCase):
# Should not raise
injector.stop()
mock_stop_proc.assert_called_once()
mock_switch.assert_not_called()
@patch("vocalinux.text_injection.ibus_engine.restore_xkb_layout")
@patch("vocalinux.text_injection.ibus_engine.stop_engine_process")
@patch("vocalinux.text_injection.ibus_engine.switch_engine")
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="xkb:es::spa")
@patch("vocalinux.text_injection.ibus_engine.switch_engine", return_value=True)
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
def test_stop_restores_xkb_layout(
self, mock_ensure_dir, mock_switch, mock_stop_proc, mock_restore_xkb
self, mock_ensure_dir, mock_switch, mock_get_current, mock_stop_proc, mock_restore_xkb
):
"""Test stop() restores the captured XKB layout (#292)."""
"""Test stop() restores XKB after post-teardown engine restore (#292/#558)."""
from vocalinux.text_injection.ibus_engine import IBusTextInjector
parent = MagicMock()
parent.attach_mock(mock_stop_proc, "stop_proc")
parent.attach_mock(mock_switch, "switch")
parent.attach_mock(mock_restore_xkb, "restore_xkb")
injector = IBusTextInjector(auto_activate=False)
injector._previous_engine = "xkb:fr::fra" # stale; live engine should win
injector._previous_xkb_layout = ("es", "catalan", "compose:menu")
injector.stop()
mock_restore_xkb.assert_called_once_with("es", "catalan", "compose:menu")
mock_stop_proc.assert_called_once()
mock_switch.assert_called_once_with("xkb:es::spa")
self.assertEqual(
[c[0] for c in parent.mock_calls],
["stop_proc", "switch", "restore_xkb"],
)
self.assertIsNone(injector._previous_xkb_layout)
@patch("vocalinux.text_injection.ibus_engine.restore_xkb_layout")
@patch("vocalinux.text_injection.ibus_engine.stop_engine_process")
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value=None)
@patch("vocalinux.text_injection.ibus_engine.switch_engine")
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
def test_stop_skips_xkb_restore_when_no_layout(
self, mock_ensure_dir, mock_switch, mock_stop_proc, mock_restore_xkb
self, mock_ensure_dir, mock_switch, mock_get_current, mock_stop_proc, mock_restore_xkb
):
"""Test stop() skips XKB restore when no layout was captured."""
from vocalinux.text_injection.ibus_engine import IBusTextInjector
@@ -720,6 +797,50 @@ class TestIBusTextInjector(unittest.TestCase):
injector.stop()
mock_restore_xkb.assert_not_called()
mock_switch.assert_not_called()
@patch("vocalinux.text_injection.ibus_engine.stop_engine_process")
@patch(
"vocalinux.text_injection.ibus_engine.get_current_engine",
return_value="vocalinux",
)
@patch("vocalinux.text_injection.ibus_engine.switch_engine", return_value=True)
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
def test_stop_uses_cached_engine_when_stuck_on_vocalinux(
self, mock_ensure_dir, mock_switch, mock_get_current, mock_stop_proc
):
"""If quit finds vocalinux selected, fall back to the warmup-captured engine (#558)."""
from vocalinux.text_injection.ibus_engine import IBusTextInjector
injector = IBusTextInjector(auto_activate=False)
injector._previous_engine = "xkb:es::spa"
injector.stop()
mock_get_current.assert_called()
mock_stop_proc.assert_called_once()
mock_switch.assert_called_once_with("xkb:es::spa")
@patch("vocalinux.text_injection.ibus_engine.stop_engine_process")
@patch(
"vocalinux.text_injection.ibus_engine.get_current_engine",
return_value="vocalinux",
)
@patch("vocalinux.text_injection.ibus_engine.switch_engine")
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
def test_stop_skips_restore_when_only_vocalinux_active(
self, mock_ensure_dir, mock_switch, mock_get_current, mock_stop_proc
):
"""Do not try to reselect vocalinux after its process has been torn down."""
from vocalinux.text_injection.ibus_engine import IBusTextInjector
injector = IBusTextInjector(auto_activate=False)
injector.stop()
mock_stop_proc.assert_called_once()
mock_switch.assert_not_called()
@patch("vocalinux.text_injection.ibus_engine.restore_xkb_layout")
@patch("vocalinux.text_injection.ibus_engine.get_current_xkb_layout")
@@ -757,6 +878,8 @@ class TestIBusTextInjector(unittest.TestCase):
self.assertEqual(injector._previous_xkb_layout, ("fr", "azerty", ""))
mock_restore_xkb.assert_called_once_with("fr", "azerty", "")
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="xkb:fr::fra")
@patch("vocalinux.text_injection.ibus_engine.is_engine_active", return_value=False)
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
@patch("vocalinux.text_injection.ibus_engine.start_engine_process", return_value=True)
@@ -768,6 +891,8 @@ class TestIBusTextInjector(unittest.TestCase):
mock_wait_ready,
mock_start_engine,
mock_ensure_dir,
mock_is_active,
mock_get_current,
):
"""Test warmup starts the process without replacing the user's IBus engine."""
from vocalinux.text_injection.ibus_engine import IBusTextInjector
@@ -779,13 +904,13 @@ class TestIBusTextInjector(unittest.TestCase):
mock_wait_ready.assert_called_once_with(require_active=False)
mock_switch.assert_not_called()
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="xkb:fr::fra")
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="xkb:es::spa")
@patch("vocalinux.text_injection.ibus_engine.is_engine_active", return_value=False)
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
@patch("vocalinux.text_injection.ibus_engine.start_engine_process", return_value=True)
@patch("vocalinux.text_injection.ibus_engine.IBusTextInjector._wait_for_engine_ready")
def test_prepare_engine_does_not_cache_restore_engine(
def test_prepare_engine_caches_shutdown_restore_engine(
self,
mock_wait_ready,
mock_start_engine,
@@ -793,15 +918,15 @@ class TestIBusTextInjector(unittest.TestCase):
mock_is_active,
mock_get_current,
):
"""Test warmup does not cache an engine that can become stale before injection."""
"""Warmup caches the live engine so quit can restore after a stuck vocalinux (#558)."""
from vocalinux.text_injection.ibus_engine import IBusTextInjector
injector = IBusTextInjector(auto_activate=False)
injector.prepare_engine()
self.assertIsNone(injector._previous_engine)
mock_get_current.assert_not_called()
mock_is_active.assert_not_called()
self.assertEqual(injector._previous_engine, "xkb:es::spa")
mock_get_current.assert_called()
mock_is_active.assert_called()
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="libpinyin")
@patch("vocalinux.text_injection.ibus_engine.is_engine_active", return_value=False)
@@ -834,11 +959,48 @@ class TestIBusTextInjector(unittest.TestCase):
self.assertTrue(result)
mock_sock.sendall.assert_called_once_with("Bonjour".encode("utf-8"))
self.assertEqual(injector._previous_engine, "libpinyin")
self.assertEqual(
[call.args[0] for call in mock_switch.call_args_list],
[ENGINE_NAME, "libpinyin"],
)
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="xkb:de::deu")
@patch("vocalinux.text_injection.ibus_engine.is_engine_active", return_value=False)
@patch("vocalinux.text_injection.ibus_engine.switch_engine", return_value=True)
@patch("socket.socket")
@patch("vocalinux.text_injection.ibus_engine.SOCKET_PATH")
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
def test_inject_text_refreshes_stale_shutdown_fallback(
self,
mock_ensure_dir,
mock_socket_path,
mock_socket_cls,
mock_switch,
mock_is_active,
mock_get_current,
):
"""inject_text overwrites a stale warmup cache with the live engine (#643 Bugbot)."""
from vocalinux.text_injection.ibus_engine import ENGINE_NAME, IBusTextInjector
mock_socket_path.exists.return_value = True
mock_sock = MagicMock()
mock_sock.__enter__.return_value = mock_sock
mock_sock.__exit__.return_value = None
mock_sock.recv.return_value = b"OK"
mock_socket_cls.return_value = mock_sock
injector = IBusTextInjector(auto_activate=False)
injector._previous_engine = "xkb:es::spa"
self.assertTrue(injector.inject_text("Hallo"))
self.assertEqual(injector._previous_engine, "xkb:de::deu")
self.assertEqual(
[call.args[0] for call in mock_switch.call_args_list],
[ENGINE_NAME, "xkb:de::deu"],
)
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="libpinyin")
@patch("vocalinux.text_injection.ibus_engine.is_engine_active", return_value=False)
@patch("vocalinux.text_injection.ibus_engine.switch_engine", side_effect=[True, False])
+6 -2
View File
@@ -574,16 +574,20 @@ class TestIBusTextInjector(unittest.TestCase):
self.assertIsNotNone(injector)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
@patch("vocalinux.text_injection.ibus_engine.stop_engine_process")
@patch("vocalinux.text_injection.ibus_engine.switch_engine")
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="other-engine")
def test_ibus_text_injector_stop(self, mock_current_engine, mock_switch, mock_ensure_dir):
"""Test IBusTextInjector stop restores previous engine."""
def test_ibus_text_injector_stop(
self, mock_current_engine, mock_switch, mock_stop_proc, mock_ensure_dir
):
"""Test IBusTextInjector stop restores previous engine after teardown."""
from vocalinux.text_injection.ibus_engine import IBusTextInjector
injector = IBusTextInjector(auto_activate=False)
injector._previous_engine = "other-engine"
injector.stop()
mock_stop_proc.assert_called_once()
mock_switch.assert_called_once_with("other-engine")
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
+82
View File
@@ -4,6 +4,7 @@ Final comprehensive tests for IBus engine.
These tests exercise code paths to improve coverage without platform-specific issues.
"""
import signal
import subprocess
import sys
import unittest
@@ -319,6 +320,87 @@ class TestStopEngineProcess(unittest.TestCase):
# Verify the function handles missing PID file gracefully
self.assertIsNone(result)
@patch("vocalinux.text_injection.ibus_engine.time.sleep", return_value=None)
@patch("vocalinux.text_injection.ibus_engine.os.kill")
@patch("vocalinux.text_injection.ibus_engine.Path")
@patch("vocalinux.text_injection.ibus_engine.PID_FILE")
def test_stop_engine_process_waits_for_exit(
self, mock_pid_file, mock_path_cls, mock_kill, mock_sleep
):
"""SIGTERM path must wait until the process is gone before returning (#558)."""
from vocalinux.text_injection.ibus_engine import stop_engine_process
mock_pid_file.exists.return_value = True
mock_pid_file.read_text.return_value = "4242\n"
cmdline = MagicMock()
cmdline.exists.return_value = True
cmdline.read_text.return_value = "python /x/vocalinux/text_injection/ibus_engine.py --ibus"
mock_path_cls.return_value = cmdline
# SIGTERM succeeds; first liveness check still alive; second gone.
mock_kill.side_effect = [None, None, OSError(3, "No such process")]
stop_engine_process(wait_timeout=1.0)
self.assertEqual(mock_kill.call_args_list[0][0], (4242, signal.SIGTERM))
self.assertGreaterEqual(mock_kill.call_count, 3)
mock_pid_file.unlink.assert_called()
mock_sleep.assert_called()
@patch("vocalinux.text_injection.ibus_engine.time.sleep", return_value=None)
@patch("vocalinux.text_injection.ibus_engine.os.kill")
@patch("vocalinux.text_injection.ibus_engine.Path")
@patch("vocalinux.text_injection.ibus_engine.PID_FILE")
def test_stop_engine_process_sigkills_when_term_ignored(
self, mock_pid_file, mock_path_cls, mock_kill, mock_sleep
):
"""Escalate to SIGKILL if the engine ignores SIGTERM (#558 wait path)."""
from vocalinux.text_injection.ibus_engine import stop_engine_process
mock_pid_file.exists.return_value = True
mock_pid_file.read_text.return_value = "4242\n"
cmdline = MagicMock()
cmdline.exists.return_value = True
cmdline.read_text.return_value = "python /x/vocalinux/text_injection/ibus_engine.py --ibus"
mock_path_cls.return_value = cmdline
# SIGTERM, then keep reporting alive until wait timeout, then SIGKILL succeeds.
mock_kill.side_effect = [None, None, None, OSError(3, "No such process")]
with patch(
"vocalinux.text_injection.ibus_engine.time.monotonic",
side_effect=[0.0, 0.0, 2.1, 2.1, 2.2, 2.7],
):
stop_engine_process(wait_timeout=2.0)
self.assertEqual(mock_kill.call_args_list[0][0], (4242, signal.SIGTERM))
self.assertTrue(any(c[0] == (4242, signal.SIGKILL) for c in mock_kill.call_args_list))
mock_pid_file.unlink.assert_called()
class TestStopRestoreRetry(unittest.TestCase):
"""Coverage for stop() restore retry after teardown."""
@patch("vocalinux.text_injection.ibus_engine.stop_engine_process")
@patch("vocalinux.text_injection.ibus_engine.get_current_engine", return_value="xkb:es::spa")
@patch("vocalinux.text_injection.ibus_engine.switch_engine", return_value=False)
@patch("vocalinux.text_injection.ibus_engine.time.sleep", return_value=None)
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
@patch("vocalinux.text_injection.ibus_engine.ensure_ibus_dir")
def test_stop_retries_failed_engine_restore(
self, mock_ensure_dir, mock_sleep, mock_switch, mock_get_current, mock_stop_proc
):
"""stop() retries switch_engine after teardown when the first attempts fail."""
from vocalinux.text_injection.ibus_engine import IBusTextInjector
injector = IBusTextInjector(auto_activate=False)
injector.stop()
self.assertEqual(mock_switch.call_count, 3)
mock_switch.assert_called_with("xkb:es::spa")
self.assertEqual(mock_sleep.call_count, 3)
if __name__ == "__main__":
unittest.main()