Repository: qutebrowser/qutebrowser

Issue:
Allow reloading config with HUP signal
## Description

When sending the HUP signal to Qutebrowser, it should reload the config file(s) just like when running `:config-source`.

## Use cases

- Automatically reloading qutebrowser when changing a Home-manager configuration on Nix systems
- Automatically reloading qutebrowser after changing its settings from a script, to change font size and zoom when connecting a new display for example.

## Current alternatives

If you are the user running Qb, run `qutebrowser :config-source` which will run the command (side-effect: it will also focus the Qutebrowser window like when opening a web page).


Discussion:
I think that sounds harmless enough. Regarding the caveat to the "current alternative" about the window being raised, I wanted to link the issue about that, but all I could find was this one (which was apparently fixed at one point!): https://github.com/qutebrowser/qutebrowser/issues/1328

Resolving patch:
diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py
--- a/qutebrowser/misc/crashsignal.py
+++ b/qutebrowser/misc/crashsignal.py
@@ -22,8 +22,9 @@
 from qutebrowser.qt.widgets import QApplication
 
 from qutebrowser.api import cmdutils
+from qutebrowser.config import configfiles, configexc
 from qutebrowser.misc import earlyinit, crashdialog, ipc, objects
-from qutebrowser.utils import usertypes, standarddir, log, objreg, debug, utils
+from qutebrowser.utils import usertypes, standarddir, log, objreg, debug, utils, message
 from qutebrowser.qt import sip
 if TYPE_CHECKING:
     from qutebrowser.misc import quitter
@@ -322,6 +323,17 @@ def __init__(self, *, app, quitter, parent=None):
         self._activated = False
         self._orig_wakeup_fd: Optional[int] = None
 
+        self._handlers = {
+            signal.SIGINT: self.interrupt,
+            signal.SIGTERM: self.interrupt,
+        }
+        platform_dependant_handlers = {
+            "SIGHUP": self.reload_config,
+        }
+        for sig_str, handler in platform_dependant_handlers.items():
+            if hasattr(signal.Signals, sig_str):
+                self._handlers[signal.Signals[sig_str]] = handler
+
     def activate(self):
         """Set up signal handlers.
 
@@ -331,10 +343,8 @@ def activate(self):
         On Unix, it uses a QSocketNotifier with os.set_wakeup_fd to get
         notified.
         """
-        self._orig_handlers[signal.SIGINT] = signal.signal(
-            signal.SIGINT, self.interrupt)
-        self._orig_handlers[signal.SIGTERM] = signal.signal(
-            signal.SIGTERM, self.interrupt)
+        for sig, handler in self._handlers.items():
+            self._orig_handlers[sig] = signal.signal(sig, handler)
 
         if utils.is_posix and hasattr(signal, 'set_wakeup_fd'):
             # pylint: disable=import-error,no-member,useless-suppression
@@ -430,6 +440,15 @@ def interrupt_really_forcefully(self, signum, _frame):
         print("WHY ARE YOU DOING THIS TO ME? :(")
         sys.exit(128 + signum)
 
+    def reload_config(self, _signum, _frame):
+        """Reload the config."""
+        log.signals.info("SIGHUP received, reloading config.")
+        filename = standarddir.config_py()
+        try:
+            configfiles.read_config_py(filename)
+        except configexc.ConfigFileErrors as e:
+            message.error(str(e))
+
 
 def init(q_app: QApplication,
          args: argparse.Namespace,


Test patch:
diff --git a/tests/unit/misc/test_crashsignal.py b/tests/unit/misc/test_crashsignal.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/misc/test_crashsignal.py
@@ -0,0 +1,104 @@
+# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+"""Tests for qutebrowser.misc.crashsignal."""
+
+import signal
+
+import pytest
+
+from qutebrowser.config import configexc
+from qutebrowser.qt.widgets import QApplication
+from qutebrowser.misc import crashsignal, quitter
+
+
+@pytest.fixture
+def read_config_mock(mocker):
+    # covers reload_config
+    mocker.patch.object(
+        crashsignal.standarddir,
+        "config_py",
+        return_value="config.py-unittest",
+    )
+    return mocker.patch.object(
+        crashsignal.configfiles,
+        "read_config_py",
+        autospec=True,
+    )
+
+
+@pytest.fixture
+def signal_handler(qtbot, mocker, read_config_mock):
+    """Signal handler instance with all external methods mocked out."""
+    # covers init
+    mocker.patch.object(crashsignal.sys, "exit", autospec=True)
+    signal_handler = crashsignal.SignalHandler(
+        app=mocker.Mock(spec=QApplication),
+        quitter=mocker.Mock(spec=quitter.Quitter),
+    )
+
+    return signal_handler
+
+
+def test_handlers_registered(signal_handler):
+    signal_handler.activate()
+
+    for sig, handler in signal_handler._handlers.items():
+        registered = signal.signal(sig, signal.SIG_DFL)
+        assert registered == handler
+
+
+def test_handlers_deregistered(signal_handler):
+    known_handler = lambda *_args: None
+    for sig in signal_handler._handlers:
+        signal.signal(sig, known_handler)
+
+    signal_handler.activate()
+    signal_handler.deactivate()
+
+    for sig in signal_handler._handlers:
+        registered = signal.signal(sig, signal.SIG_DFL)
+        assert registered == known_handler
+
+
+def test_interrupt_repeatedly(signal_handler):
+    signal_handler.activate()
+    test_signal = signal.SIGINT
+
+    expected_handlers = [
+        signal_handler.interrupt,
+        signal_handler.interrupt_forcefully,
+        signal_handler.interrupt_really_forcefully,
+    ]
+
+    # Call the SIGINT handler multiple times and make sure it calls the
+    # expected sequence of functions.
+    for expected in expected_handlers:
+        registered = signal.signal(test_signal, signal.SIG_DFL)
+        assert registered == expected
+        expected(test_signal, None)
+
+
+@pytest.mark.posix
+def test_reload_config_call_on_hup(signal_handler, read_config_mock):
+    signal_handler._handlers[signal.SIGHUP](None, None)
+
+    read_config_mock.assert_called_once_with("config.py-unittest")
+
+
+@pytest.mark.posix
+def test_reload_config_displays_errors(signal_handler, read_config_mock, mocker):
+    read_config_mock.side_effect = configexc.ConfigFileErrors(
+        "config.py",
+        [
+            configexc.ConfigErrorDesc("no config.py", ValueError("asdf"))
+        ]
+    )
+    message_mock = mocker.patch.object(crashsignal.message, "error")
+
+    signal_handler._handlers[signal.SIGHUP](None, None)
+
+    message_mock.assert_called_once_with(
+        "Errors occurred while reading config.py:\n  no config.py: asdf"
+    )