Replace crash guard with SIGUSR1 unlock and crash logging

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).
This commit is contained in:
nevaforget 2026-03-26 21:11:42 +01:00
parent 3f31387305
commit 5fda0dce0c
3 changed files with 48 additions and 48 deletions

View File

@ -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"

View File

@ -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)

View File

@ -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