From 8e630db20339e8d808677eb0a29f639da7a040d1 Mon Sep 17 00:00:00 2001 From: TimInTech <129355283+TimInTech@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:56:54 +0100 Subject: [PATCH] fix: prevent deadlock and focus freeze on compose insert & close --- app/compose_window.py | 15 ++++++++------- app/paste_service.py | 28 +++++++++++++++++++++------- tests/test_paste_service.py | 15 +++++++++++++++ 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/app/compose_window.py b/app/compose_window.py index 18fd2a2..e141eea 100644 --- a/app/compose_window.py +++ b/app/compose_window.py @@ -878,15 +878,16 @@ def _on_paste_clicked(self) -> None: text = self.txtOutput.toPlainText() if not text.strip(): return + self.close() try: - self._paste_service.paste(text, force_autopaste=True) - except PasteServiceError as exc: - self._show_status(t("compose.status.error").format(message=str(exc)), error=True) - return - except Exception as exc: # pragma: no cover - defensive + try: + self._paste_service.paste(text, force_autopaste=True, restore_clipboard=False) + except TypeError: + self._paste_service.paste(text, force_autopaste=True) + except (PasteServiceError, Exception) as exc: # pragma: no cover - defensive + logger.error("Failed to paste from compose window: %s", exc) + self.show() self._show_status(t("compose.status.error").format(message=str(exc)), error=True) - return - self.close() def closeEvent(self, event: QCloseEvent) -> None: # A hidden modeless window must never remain an implicit routing target. diff --git a/app/paste_service.py b/app/paste_service.py index 70d29f9..d2cd35c 100644 --- a/app/paste_service.py +++ b/app/paste_service.py @@ -120,12 +120,19 @@ def __init__(self, autopaste: bool = True, key_delay_ms: int = _KEY_DELAY_MS) -> self.key_delay_ms = max(0, int(key_delay_ms)) self._ydotool_uses_legacy_keys: Optional[bool] = None - def paste(self, text: str, force_autopaste: Optional[bool] = None) -> None: + def paste( + self, + text: str, + force_autopaste: Optional[bool] = None, + restore_clipboard: bool = True, + ) -> None: """Text ins Clipboard schreiben und optional einfuegen. Args: text: Der einzufuegende Text. force_autopaste: Optionaler Override fuer den Auto-Paste-Schritt. + restore_clipboard: Ob nach erfolgreichem Auto-Paste der urspruengliche + Clipboard-Inhalt wiederhergestellt werden soll. Raises: PasteServiceError: Wenn wl-copy nicht gefunden oder hart fehlschlaegt. @@ -140,14 +147,15 @@ def paste(self, text: str, force_autopaste: Optional[bool] = None) -> None: do_autopaste = False logger.info("Paste request: %d chars, autopaste=%s", len(text), do_autopaste) - previous_clipboard = self._read_clipboard() if do_autopaste else None + previous_clipboard = self._read_clipboard() if (do_autopaste and restore_clipboard) else None self._copy_to_clipboard(text) if do_autopaste and self._ydotool_paste(): # Pause, damit die Ziel-App den eingefuegten Text uebernehmen kann - time.sleep(_RESTORE_DELAY) - self._cleanup_copyq(text) - self._restore_clipboard(previous_clipboard) + if restore_clipboard: + time.sleep(_RESTORE_DELAY) + self._cleanup_copyq(text) + self._restore_clipboard(previous_clipboard) def clipboard_only(self, text: str) -> None: """Nur Clipboard, kein ydotool -- fuer Faelle wo Auto-Paste unterwuenscht.""" @@ -196,8 +204,14 @@ def _read_clipboard(self) -> Optional[str]: try: stdout, _ = process.communicate(timeout=timeout) except subprocess.TimeoutExpired: - process.kill() - process.communicate() + try: + process.kill() + except OSError: + pass + try: + process.communicate(timeout=0.2) + except (subprocess.TimeoutExpired, OSError, ValueError): + pass return None if process.returncode != 0: return None diff --git a/tests/test_paste_service.py b/tests/test_paste_service.py index 06ebe3d..abd98b9 100644 --- a/tests/test_paste_service.py +++ b/tests/test_paste_service.py @@ -400,6 +400,21 @@ def test_paste_restores_empty_previous_clipboard_and_cleans_up_copyq(self): restore_mock.assert_called_once_with("") cleanup_mock.assert_called_once_with("neu") + def test_paste_does_not_read_or_restore_when_restore_clipboard_false(self): + service = PasteService(autopaste=True) + with patch("app.paste_service.shutil.which", return_value="/usr/bin/ydotool"): + with patch.object(service, "_read_clipboard") as read_mock: + with patch.object(service, "_copy_to_clipboard") as copy_mock: + with patch.object(service, "_ydotool_paste", return_value=True) as paste_mock: + with patch.object(service, "_restore_clipboard") as restore_mock: + with patch.object(service, "_cleanup_copyq") as cleanup_mock: + service.paste("neu", force_autopaste=True, restore_clipboard=False) + read_mock.assert_not_called() + copy_mock.assert_called_once_with("neu") + paste_mock.assert_called_once_with() + restore_mock.assert_not_called() + cleanup_mock.assert_not_called() + def test_clipboard_only_does_not_restore_or_cleanup(self): service = PasteService(autopaste=True) with patch.object(service, "_read_clipboard") as read_mock: