From 5fda0dce0ca2bd576ae56444414f00706665fece Mon Sep 17 00:00:00 2001 From: nevaforget Date: Thu, 26 Mar 2026 21:11:42 +0100 Subject: [PATCH] Replace crash guard with SIGUSR1 unlock and crash logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove sys.excepthook that unlocked on crash — this violated ext-session-lock-v1 security model where the compositor must keep the screen locked if the client dies (per protocol spec and hyprlock reference). Now: crashes are logged but session stays locked. SIGUSR1 handler added for external recovery (e.g. wrapper script). --- pyproject.toml | 2 +- src/moonlock/main.py | 35 +++++++++++++++----------- tests/test_main.py | 59 ++++++++++++++++++++------------------------ 3 files changed, 48 insertions(+), 48 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c5c5ab5..129bf3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "moonlock" -version = "0.2.1" +version = "0.2.2" description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support" requires-python = ">=3.11" license = "MIT" diff --git a/src/moonlock/main.py b/src/moonlock/main.py index 99a84bc..2f94370 100644 --- a/src/moonlock/main.py +++ b/src/moonlock/main.py @@ -112,8 +112,7 @@ class MoonlockApp(Gtk.Application): logger.exception("Failed to create lockscreen window for monitor %d", i) if not self._windows: - logger.critical("No lockscreen windows created — unlocking session to prevent lockout") - self._lock_instance.unlock() + logger.critical("No lockscreen windows created — screen stays locked (compositor policy)") def _activate_without_lock(self) -> None: """Fallback for development — no session lock, just a window.""" @@ -146,20 +145,26 @@ class MoonlockApp(Gtk.Application): logger.exception("Failed to load CSS stylesheet") -def _install_crash_guard(app: MoonlockApp) -> None: - """Install a global exception handler that unlocks the session on crash.""" - _original_excepthook = sys.excepthook +def _install_signal_handlers(app: MoonlockApp) -> None: + """Install signal handlers for external unlock (SIGUSR1) and crash logging.""" + import signal + from gi.repository import GLib - def _crash_guard(exc_type, exc_value, exc_tb): - logger.critical("Unhandled exception — unlocking session to prevent lockout", exc_info=(exc_type, exc_value, exc_tb)) - if app._lock_instance: - try: - app._lock_instance.unlock() - except Exception: - pass - _original_excepthook(exc_type, exc_value, exc_tb) + def _handle_unlock(signum, frame): + """SIGUSR1: External unlock request (e.g. from recovery wrapper).""" + logger.info("Received SIGUSR1 — unlocking session") + GLib.idle_add(app._unlock) - sys.excepthook = _crash_guard + def _handle_crash_log(signum, frame): + """Log unhandled exceptions but do NOT unlock — compositor keeps screen locked.""" + logger.critical("Unhandled exception in moonlock", exc_info=True) + + signal.signal(signal.SIGUSR1, _handle_unlock) + sys.excepthook = lambda exc_type, exc_value, exc_tb: ( + logger.critical("Unhandled exception — screen stays locked (compositor policy)", + exc_info=(exc_type, exc_value, exc_tb)), + sys.__excepthook__(exc_type, exc_value, exc_tb), + ) def main() -> None: @@ -167,7 +172,7 @@ def main() -> None: _setup_logging() logger.info("Moonlock starting") app = MoonlockApp() - _install_crash_guard(app) + _install_signal_handlers(app) app.run(sys.argv) diff --git a/tests/test_main.py b/tests/test_main.py index bc0e3cd..16f5675 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -170,10 +170,10 @@ class TestDefensiveWindowCreation: @patch("moonlock.main.FingerprintListener") @patch("moonlock.main.Gdk.Display.get_default") @patch("moonlock.main.Gtk4SessionLock") - def test_all_windows_fail_unlocks_session( + def test_all_windows_fail_does_not_unlock_session( self, mock_session_lock, mock_display, mock_fp, mock_window_cls ): - """If ALL windows fail to create, session should be unlocked to prevent lockout.""" + """If ALL windows fail, session stays locked (compositor policy).""" MoonlockApp, _ = _import_main() app = MoonlockApp.__new__(MoonlockApp) app._config = MagicMock() @@ -195,48 +195,43 @@ class TestDefensiveWindowCreation: with patch("moonlock.main.logger"): app._activate_with_session_lock() - # Session should have been unlocked to prevent permanent lockout - lock_instance.unlock.assert_called_once() + # Session must NOT be unlocked — compositor keeps screen locked + lock_instance.unlock.assert_not_called() -class TestCrashGuard: - """Tests for the global exception handler that prevents lockout on crash.""" +class TestSignalHandlers: + """Tests for signal handlers and excepthook behavior.""" - def test_crash_guard_unlocks_session_on_unhandled_exception(self): - """sys.excepthook should unlock the session when an exception reaches it.""" + def test_sigusr1_triggers_unlock(self): + """SIGUSR1 should schedule an unlock via GLib.idle_add.""" MoonlockApp, _ = _import_main() - from moonlock.main import _install_crash_guard + from moonlock.main import _install_signal_handlers + import signal + + app = MoonlockApp.__new__(MoonlockApp) + app._lock_instance = MagicMock() + + _install_signal_handlers(app) + + handler = signal.getsignal(signal.SIGUSR1) + assert handler is not signal.SIG_DFL + + def test_excepthook_does_not_unlock(self): + """Unhandled exceptions must NOT unlock the session.""" + MoonlockApp, _ = _import_main() + from moonlock.main import _install_signal_handlers app = MoonlockApp.__new__(MoonlockApp) app._lock_instance = MagicMock() original_hook = sys.excepthook - _install_crash_guard(app) - - try: - # Simulate an unhandled exception reaching the hook - with patch("moonlock.main.logger"): - sys.excepthook(RuntimeError, RuntimeError("segfault"), None) - - app._lock_instance.unlock.assert_called_once() - finally: - sys.excepthook = original_hook - - def test_crash_guard_survives_unlock_failure(self): - """Crash guard should not raise even if unlock() itself fails.""" - MoonlockApp, _ = _import_main() - from moonlock.main import _install_crash_guard - - app = MoonlockApp.__new__(MoonlockApp) - app._lock_instance = MagicMock() - app._lock_instance.unlock.side_effect = Exception("protocol error") - - original_hook = sys.excepthook - _install_crash_guard(app) + _install_signal_handlers(app) try: with patch("moonlock.main.logger"): - # Should not raise despite unlock failure sys.excepthook(RuntimeError, RuntimeError("crash"), None) + + # Must NOT have called unlock + app._lock_instance.unlock.assert_not_called() finally: sys.excepthook = original_hook