diff --git a/src/tribler/core/components/gui_process_watcher/gui_process_watcher.py b/src/tribler/core/components/gui_process_watcher/gui_process_watcher.py new file mode 100644 index 00000000000..99e4f9af16e --- /dev/null +++ b/src/tribler/core/components/gui_process_watcher/gui_process_watcher.py @@ -0,0 +1,63 @@ +import logging +import os +from typing import Callable, Optional + +import psutil +from ipv8.taskmanager import TaskManager + + +GUI_PID_ENV_KEY = 'TRIBLER_GUI_PID' +CHECK_INTERVAL = 10 + + +logger = logging.getLogger(__name__) + + +class GuiProcessNotRunning(Exception): + pass + + +class GuiProcessWatcher(TaskManager): + + def __init__(self, gui_process: psutil.Process, shutdown_callback: Callable[[], None]): + super().__init__() + self.gui_process = gui_process + self.shutdown_callback = shutdown_callback + self.shutdown_callback_called = False + + def start(self): + self.register_task("check GUI process", self.check_gui_process, interval=CHECK_INTERVAL) + + async def stop(self): + await self.shutdown_task_manager() + + def check_gui_process(self): + if self.shutdown_callback_called: + logger.info('The shutdown callback was already called; skip checking the GUI process') + return + + p = self.gui_process + if p.is_running() and p.status() != psutil.STATUS_ZOMBIE: + logger.info('GUI process checked, it is still working') + else: + logger.info('GUI process is not working, initiate Core shutdown') + self.shutdown_callback_called = True + self.shutdown_callback() + + @staticmethod + def get_gui_pid() -> Optional[int]: + pid = os.environ.get(GUI_PID_ENV_KEY, None) + if pid: + try: + return int(pid) + except ValueError: + logger.warning(f'Cannot parse {GUI_PID_ENV_KEY} environment variable: {pid}') + return None + + @classmethod + def get_gui_process(cls) -> Optional[psutil.Process]: + pid = cls.get_gui_pid() + try: + return psutil.Process(pid) if pid else None + except psutil.NoSuchProcess as e: + raise GuiProcessNotRunning('The specified GUI process is not running. Is it already crashed?') from e diff --git a/src/tribler/core/components/gui_process_watcher/gui_process_watcher_component.py b/src/tribler/core/components/gui_process_watcher/gui_process_watcher_component.py new file mode 100644 index 00000000000..26935122a77 --- /dev/null +++ b/src/tribler/core/components/gui_process_watcher/gui_process_watcher_component.py @@ -0,0 +1,23 @@ +from tribler.core.components.component import Component +from tribler.core.components.gui_process_watcher.gui_process_watcher import GuiProcessWatcher + + +class GuiProcessWatcherComponent(Component): + watcher: GuiProcessWatcher = None + + async def run(self): + await super().run() + + gui_process = GuiProcessWatcher.get_gui_process() + if not gui_process: + self.logger.warning('Cannot found GUI process to watch') + return + + self.watcher = GuiProcessWatcher(gui_process, self.session.shutdown_event.set) + self.logger.info(f'Watching GUI process with pid {self.watcher.gui_process.pid}') + self.watcher.start() + + async def shutdown(self): + await super().shutdown() + if self.watcher: + await self.watcher.stop() diff --git a/src/tribler/core/components/gui_process_watcher/tests/test_gui_process_watcher.py b/src/tribler/core/components/gui_process_watcher/tests/test_gui_process_watcher.py new file mode 100644 index 00000000000..839e1e8d985 --- /dev/null +++ b/src/tribler/core/components/gui_process_watcher/tests/test_gui_process_watcher.py @@ -0,0 +1,82 @@ +import os +from unittest.mock import Mock, patch + +import psutil +import pytest + +from tribler.core.components.gui_process_watcher.gui_process_watcher import GuiProcessNotRunning, GuiProcessWatcher, \ + GUI_PID_ENV_KEY + + +def test_get_gui_pid(caplog): + with patch.dict(os.environ, {GUI_PID_ENV_KEY: ''}): + assert GuiProcessWatcher.get_gui_pid() is None + + with patch.dict(os.environ, {GUI_PID_ENV_KEY: 'abc'}): + caplog.clear() + assert GuiProcessWatcher.get_gui_pid() is None + assert caplog.records[-1].message == 'Cannot parse TRIBLER_GUI_PID environment variable: abc' + + with patch.dict(os.environ, {GUI_PID_ENV_KEY: '123'}): + assert GuiProcessWatcher.get_gui_pid() == 123 + + +def test_get_gui_process(): + # pid is not specified + with patch.dict(os.environ, {GUI_PID_ENV_KEY: ''}): + assert GuiProcessWatcher.get_gui_process() is None + + pid = os.getpid() + with patch.dict(os.environ, {GUI_PID_ENV_KEY: str(pid)}): + # Process with the specified pid exists + p = GuiProcessWatcher.get_gui_process() + assert isinstance(p, psutil.Process) + assert p.pid == pid + + # Process with the specified pid does not exist + exception = psutil.NoSuchProcess(pid, name='name', msg='msg') + with patch('psutil.Process', side_effect=exception): + with pytest.raises(GuiProcessNotRunning): + GuiProcessWatcher.get_gui_process() + + +def test_check_gui_process(caplog): + gui_process = Mock() + gui_process.is_running.return_value = True + gui_process.status.return_value = psutil.STATUS_RUNNING + + # GUI process is working + shutdown_callback = Mock() + watcher = GuiProcessWatcher(gui_process, shutdown_callback) + caplog.clear() + watcher.check_gui_process() + assert caplog.records[-1].message == 'GUI process checked, it is still working' + assert not shutdown_callback.called + assert not watcher.shutdown_callback_called + + # GUI process is zombie + gui_process.status.return_value = psutil.STATUS_ZOMBIE + watcher = GuiProcessWatcher(gui_process, shutdown_callback) + caplog.clear() + watcher.check_gui_process() + assert caplog.records[-1].message == 'GUI process is not working, initiate Core shutdown' + assert shutdown_callback.called + assert watcher.shutdown_callback_called + + # The process is not running + gui_process.is_running.return_value = False + gui_process.status.reset_mock() + watcher = GuiProcessWatcher(gui_process, shutdown_callback) + caplog.clear() + watcher.check_gui_process() + assert not gui_process.status.called + assert caplog.records[-1].message == 'GUI process is not working, initiate Core shutdown' + assert shutdown_callback.called + + # Calling check_gui_process after shutdown_callback was already called + shutdown_callback.reset_mock() + caplog.clear() + watcher.check_gui_process() + assert caplog.records[-1].message == 'The shutdown callback was already called; skip checking the GUI process' + assert watcher.shutdown_callback_called + assert not shutdown_callback.called diff --git a/src/tribler/core/components/gui_process_watcher/tests/test_gui_process_watcher_component.py b/src/tribler/core/components/gui_process_watcher/tests/test_gui_process_watcher_component.py new file mode 100644 index 00000000000..b01a54bca0a --- /dev/null +++ b/src/tribler/core/components/gui_process_watcher/tests/test_gui_process_watcher_component.py @@ -0,0 +1,15 @@ +import os +from unittest.mock import patch + +from tribler.core.components.gui_process_watcher.gui_process_watcher import GUI_PID_ENV_KEY +from tribler.core.components.gui_process_watcher.gui_process_watcher_component import GuiProcessWatcherComponent +from tribler.core.components.session import Session + + +async def test_watch_folder_component(tribler_config): + with patch.dict(os.environ, {GUI_PID_ENV_KEY: str(os.getpid())}): + components = [GuiProcessWatcherComponent()] + async with Session(tribler_config, components) as session: + comp = session.get_instance(GuiProcessWatcherComponent) + assert comp.started_event.is_set() and not comp.failed + assert comp.watcher diff --git a/src/tribler/core/start_core.py b/src/tribler/core/start_core.py index c86f69f9716..814e7e4e6fb 100644 --- a/src/tribler/core/start_core.py +++ b/src/tribler/core/start_core.py @@ -15,6 +15,7 @@ from tribler.core.components.component import Component from tribler.core.components.gigachannel.gigachannel_component import GigaChannelComponent from tribler.core.components.gigachannel_manager.gigachannel_manager_component import GigachannelManagerComponent +from tribler.core.components.gui_process_watcher.gui_process_watcher_component import GuiProcessWatcherComponent from tribler.core.components.ipv8.ipv8_component import Ipv8Component from tribler.core.components.key.key_component import KeyComponent from tribler.core.components.libtorrent.libtorrent_component import LibtorrentComponent @@ -49,6 +50,7 @@ def components_gen(config: TriblerConfig): """This function defines components that will be used in Tibler """ yield ReporterComponent() + yield GuiProcessWatcherComponent() if config.api.http_enabled or config.api.https_enabled: yield RESTComponent() if config.chant.enabled or config.torrent_checking.enabled: diff --git a/src/tribler/gui/core_manager.py b/src/tribler/gui/core_manager.py index dddc13e632c..0d81a2b5c8f 100644 --- a/src/tribler/gui/core_manager.py +++ b/src/tribler/gui/core_manager.py @@ -86,6 +86,7 @@ def start_tribler_core(self): core_env.insert("CORE_API_PORT", f"{self.api_port}") core_env.insert("CORE_API_KEY", self.api_key) core_env.insert("TSTATEDIR", str(self.root_state_dir)) + core_env.insert("TRIBLER_GUI_PID", str(os.getpid())) core_args = self.core_args if not core_args: