From 0d51bff336f5e5d4cfd596b09d44a06dd95e89ca Mon Sep 17 00:00:00 2001 From: Alexander Kozlovsky Date: Mon, 30 Oct 2023 14:14:17 +0100 Subject: [PATCH] Use filelock as a locking mechanism to determine the primary process --- requirements-core.txt | 1 + src/run_tribler_headless.py | 18 +- src/tribler/core/start_core.py | 21 +- src/tribler/core/utilities/process_locking.py | 18 ++ .../core/utilities/process_manager/manager.py | 63 ++++-- .../core/utilities/process_manager/process.py | 39 +--- .../process_manager/tests/conftest.py | 5 +- .../process_manager/tests/test_manager.py | 183 ++++++++++++++---- .../process_manager/tests/test_process.py | 10 +- .../utilities/tests/test_process_locking.py | 20 ++ .../core/utilities/tiny_tribler_service.py | 19 +- src/tribler/gui/start_gui.py | 23 +-- src/tribler/gui/tests/test_gui.py | 8 +- 13 files changed, 293 insertions(+), 135 deletions(-) create mode 100644 src/tribler/core/utilities/process_locking.py create mode 100644 src/tribler/core/utilities/tests/test_process_locking.py diff --git a/requirements-core.txt b/requirements-core.txt index 69fad68526a..2fdb49236cc 100644 --- a/requirements-core.txt +++ b/requirements-core.txt @@ -27,3 +27,4 @@ libtorrent==1.2.15 file-read-backwards==2.0.0 Brotli==1.0.9 # to prevent AttributeError on macOs: module 'brotli' has no attribute 'error' (in urllib3.response) human-readable==1.3.2 +filelock==3.13.0 diff --git a/src/run_tribler_headless.py b/src/run_tribler_headless.py index 3380936c559..95822514281 100644 --- a/src/run_tribler_headless.py +++ b/src/run_tribler_headless.py @@ -13,13 +13,16 @@ from socket import inet_aton from typing import Optional +from filelock import FileLock + from tribler.core.components.session import Session from tribler.core.config.tribler_config import TriblerConfig from tribler.core.start_core import components_gen from tribler.core.utilities.osutils import get_appstate_dir, get_root_state_directory from tribler.core.utilities.path_util import Path -from tribler.core.utilities.process_manager import ProcessKind, ProcessManager, TriblerProcess, \ - set_global_process_manager +from tribler.core.utilities.process_locking import CORE_LOCK_FILENAME, try_acquire_file_lock +from tribler.core.utilities.process_manager import ProcessKind, ProcessManager +from tribler.core.utilities.process_manager.manager import setup_process_manager class IPPortAction(argparse.Action): @@ -47,6 +50,7 @@ def __init__(self): """ self.session = None self._stopping = False + self.process_lock: Optional[FileLock] = None self.process_manager: Optional[ProcessManager] = None def log_incoming_remote_search(self, sock_addr, keywords): @@ -68,6 +72,7 @@ async def signal_handler(sig): print("Tribler shut down") get_event_loop().stop() self.process_manager.current_process.finish() + self.process_lock.release() signal.signal(signal.SIGINT, lambda sig, _: ensure_future(signal_handler(sig))) signal.signal(signal.SIGTERM, lambda sig, _: ensure_future(signal_handler(sig))) @@ -78,11 +83,12 @@ async def signal_handler(sig): # Check if we are already running a Tribler instance root_state_dir = get_root_state_directory(create=True) - current_process = TriblerProcess.current_process(ProcessKind.Core) - self.process_manager = ProcessManager(root_state_dir, current_process) - set_global_process_manager(self.process_manager) + self.process_lock = try_acquire_file_lock(root_state_dir / CORE_LOCK_FILENAME) + current_process_owns_lock = bool(self.process_lock) + + self.process_manager = setup_process_manager(root_state_dir, ProcessKind.Core, current_process_owns_lock) - if not self.process_manager.current_process.become_primary(): + if not current_process_owns_lock: msg = 'Another Core process is already running' print(msg) self.process_manager.sys_exit(1, msg) diff --git a/src/tribler/core/start_core.py b/src/tribler/core/start_core.py index 746a1cc577e..28ab6352644 100644 --- a/src/tribler/core/start_core.py +++ b/src/tribler/core/start_core.py @@ -40,8 +40,9 @@ from tribler.core.sentry_reporter.sentry_reporter import SentryReporter, SentryStrategy from tribler.core.upgrade.version_manager import VersionHistory from tribler.core.utilities import slow_coro_detection -from tribler.core.utilities.process_manager import ProcessKind, ProcessManager, TriblerProcess, \ - set_global_process_manager +from tribler.core.utilities.process_locking import CORE_LOCK_FILENAME, try_acquire_file_lock +from tribler.core.utilities.process_manager import ProcessKind +from tribler.core.utilities.process_manager.manager import setup_process_manager logger = logging.getLogger(__name__) CONFIG_FILE_NAME = 'triblerd.conf' @@ -186,15 +187,17 @@ def run_tribler_core_session(api_port: Optional[int], api_key: str, def run_core(api_port: Optional[int], api_key: Optional[str], root_state_dir, parsed_args): logger.info(f"Running Core in {'gui_test_mode' if parsed_args.gui_test_mode else 'normal mode'}") - gui_pid = GuiProcessWatcher.get_gui_pid() - current_process = TriblerProcess.current_process(ProcessKind.Core, creator_pid=gui_pid) - process_manager = ProcessManager(root_state_dir, current_process) - set_global_process_manager(process_manager) - current_process_is_primary = process_manager.current_process.become_primary() + process_lock = try_acquire_file_lock(root_state_dir / CORE_LOCK_FILENAME) + current_process_owns_lock = bool(process_lock) - load_logger_config('tribler-core', root_state_dir, current_process_is_primary) + gui_process_pid = GuiProcessWatcher.get_gui_pid() - if not current_process_is_primary: + process_manager = setup_process_manager(root_state_dir, ProcessKind.Core, current_process_owns_lock, + creator_pid=gui_process_pid) + + load_logger_config('tribler-core', root_state_dir, current_process_owns_lock) + + if not current_process_owns_lock: msg = 'Another Core process is already running' logger.warning(msg) process_manager.sys_exit(1, msg) diff --git a/src/tribler/core/utilities/process_locking.py b/src/tribler/core/utilities/process_locking.py new file mode 100644 index 00000000000..30a7a7d29b4 --- /dev/null +++ b/src/tribler/core/utilities/process_locking.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Optional + +from filelock import FileLock, Timeout + + +GUI_LOCK_FILENAME = 'tribler-gui.lock' +CORE_LOCK_FILENAME = 'tribler-core.lock' + + +def try_acquire_file_lock(lock_file_name) -> Optional[FileLock]: + lock = FileLock(lock_file_name) + try: + lock.acquire(blocking=False) + except Timeout: + return None + return lock diff --git a/src/tribler/core/utilities/process_manager/manager.py b/src/tribler/core/utilities/process_manager/manager.py index 37f18ccd06c..582db78a0b4 100644 --- a/src/tribler/core/utilities/process_manager/manager.py +++ b/src/tribler/core/utilities/process_manager/manager.py @@ -34,14 +34,40 @@ def get_global_process_manager() -> Optional[ProcessManager]: return global_process_manager +def setup_process_manager(root_state_dir: Path, process_kind: ProcessKind, current_process_owns_lock: bool, + creator_pid: Optional[int] = None) -> ProcessManager: + process_manager = ProcessManager(root_state_dir) + process_manager.setup_current_process(kind=process_kind, owns_lock=current_process_owns_lock, + creator_pid=creator_pid) + set_global_process_manager(process_manager) + return process_manager + + class ProcessManager: - def __init__(self, root_dir: Path, current_process: TriblerProcess, db_filename: str = DB_FILENAME): + def __init__(self, root_dir: Path, db_filename: str = DB_FILENAME): self.logger = logger # Used by the `with_retry` decorator self.root_dir = root_dir self.db_filepath = root_dir / db_filename self.connection: Optional[sqlite3.Connection] = None - self.current_process = current_process - current_process.manager = self + self._current_process: Optional[TriblerProcess] = None + + @with_retry + def setup_current_process(self, kind: ProcessKind, owns_lock: bool, creator_pid: Optional[int] = None): + current_process = TriblerProcess.current_process(manager=self, kind=kind, owns_lock=owns_lock, + creator_pid=creator_pid) + self._current_process = current_process + with self.connect(): + if current_process.primary: + if primary_process := self.get_primary_process(current_process.kind): + raise RuntimeError(f'Previous primary process still active: {primary_process}. ' + f'Current process: {current_process}') + current_process.save() + + @property + def current_process(self): + if self._current_process is None: + raise RuntimeError('Current process is not set') + return self._current_process @contextmanager def connect(self) -> ContextManager[sqlite3.Connection]: @@ -105,27 +131,38 @@ def _unable_to_open_db_file_get_reason(self): return 'unknown reason' - def primary_process_rowid(self, kind: ProcessKind) -> Optional[int]: + def get_primary_process(self, kind: ProcessKind) -> Optional[TriblerProcess]: """ A helper method to load the current primary process of the specified kind from the database. - Returns rowid of the existing process or None. + Returns existing primary process or None. """ with self.connect() as connection: cursor = connection.execute(f""" SELECT {sql_scripts.SELECT_COLUMNS} - FROM processes WHERE kind = ? and "primary" = 1 ORDER BY rowid DESC LIMIT 1 + FROM processes WHERE kind = ? and "primary" = 1 ORDER BY rowid """, [kind.value]) - row = cursor.fetchone() - if row is not None: + + primary_processes = [] # In normal situation there should be at most one primary process + rows = cursor.fetchall() + for row in rows: process = TriblerProcess.from_row(self, row) if process.is_running(): - return process.rowid + primary_processes.append(process) + else: + # Process is not running anymore; mark it as not primary + process.primary = False + process.save() + + if not primary_processes: + return None + + if len(primary_processes) > 1: + for process in primary_processes: + process.error_msg = "Multiple primary processes found in the database" + process.save() - # Process is not running anymore; mark it as not primary - process.primary = False - process.save() - return None + return primary_processes[0] def sys_exit(self, exit_code: Optional[int] = None, error: Optional[str | Exception] = None, replace: bool = False): """ diff --git a/src/tribler/core/utilities/process_manager/process.py b/src/tribler/core/utilities/process_manager/process.py index 875d5c43754..01b2edc6054 100644 --- a/src/tribler/core/utilities/process_manager/process.py +++ b/src/tribler/core/utilities/process_manager/process.py @@ -24,12 +24,11 @@ class ProcessKind(Enum): class TriblerProcess: - def __init__(self, pid: int, kind: ProcessKind, app_version: str, started_at: int, + def __init__(self, manager: ProcessManager, pid: int, kind: ProcessKind, app_version: str, started_at: int, row_version: int = 0, rowid: Optional[int] = None, creator_pid: Optional[int] = None, primary: bool = False, canceled: bool = False, api_port: Optional[int] = None, - finished_at: Optional[int] = None, exit_code: Optional[int] = None, error_msg: Optional[str] = None, - manager: Optional[ProcessManager] = None): - self._manager = manager + finished_at: Optional[int] = None, exit_code: Optional[int] = None, error_msg: Optional[str] = None): + self.manager = manager self.rowid = rowid self.row_version = row_version self.pid = pid @@ -44,16 +43,6 @@ def __init__(self, pid: int, kind: ProcessKind, app_version: str, started_at: in self.exit_code = exit_code self.error_msg = error_msg - @property - def manager(self) -> ProcessManager: - if self._manager is None: - raise RuntimeError('Tribler process manager is not set in process object') - return self._manager - - @manager.setter - def manager(self, manager: ProcessManager): - self._manager = manager - @property def logger(self) -> logging.Logger: """Used by the `with_retry` decorator""" @@ -128,35 +117,19 @@ def __str__(self) -> str: return ''.join(result) @classmethod - def current_process(cls, kind: ProcessKind, - creator_pid: Optional[int] = None, - manager: Optional[ProcessManager] = None) -> TriblerProcess: + def current_process(cls, manager: ProcessManager, kind: ProcessKind, owns_lock: bool, + creator_pid: Optional[int] = None) -> TriblerProcess: """Constructs an object for a current process, specifying the PID value of the current process""" pid = os.getpid() psutil_process = psutil.Process(pid) started_at = int(psutil_process.create_time()) - return cls(manager=manager, row_version=0, pid=pid, kind=kind, + return cls(manager=manager, row_version=0, pid=pid, kind=kind, primary=owns_lock, app_version=version_id, started_at=started_at, creator_pid=creator_pid) def is_current_process(self) -> bool: """Returns True if the object represents the current process""" return self.pid == os.getpid() and self.is_running() - @with_retry - def become_primary(self) -> bool: - """ - If there is no primary process already, makes the current process primary and returns the primary status - """ - with self.manager.connect(): - # for a new process object self.rowid is None - primary_rowid = self.manager.primary_process_rowid(self.kind) - if primary_rowid is None or primary_rowid == self.rowid: - self.primary = True - else: - self.canceled = True - self.save() - return bool(self.primary) - def is_running(self): """Returns True if the object represents a running process""" if not psutil.pid_exists(self.pid): diff --git a/src/tribler/core/utilities/process_manager/tests/conftest.py b/src/tribler/core/utilities/process_manager/tests/conftest.py index ad51b460b96..8193caa592a 100644 --- a/src/tribler/core/utilities/process_manager/tests/conftest.py +++ b/src/tribler/core/utilities/process_manager/tests/conftest.py @@ -8,7 +8,6 @@ @pytest.fixture(name='process_manager') def process_manager_fixture(tmp_path: Path) -> ProcessManager: # Creates a process manager with a new database and adds a primary current process to it - current_process = TriblerProcess.current_process(ProcessKind.Core) - process_manager = ProcessManager(tmp_path, current_process) - current_process.become_primary() + process_manager = ProcessManager(tmp_path) + process_manager.setup_current_process(kind=ProcessKind.Core, owns_lock=True) return process_manager diff --git a/src/tribler/core/utilities/process_manager/tests/test_manager.py b/src/tribler/core/utilities/process_manager/tests/test_manager.py index 70002390280..08a672e93e6 100644 --- a/src/tribler/core/utilities/process_manager/tests/test_manager.py +++ b/src/tribler/core/utilities/process_manager/tests/test_manager.py @@ -1,6 +1,6 @@ import sqlite3 import time -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pytest @@ -11,48 +11,151 @@ # pylint: disable=protected-access -def test_become_primary(process_manager: ProcessManager): - # Initially process manager fixture creates a primary current process that is a single process in DB - p1 = process_manager.current_process - assert p1.primary +def test_current_process_not_set(tmp_path): + # This test verifies that the `current_process` property of the `ProcessManager` class raises a + # RuntimeError when accessed before an actual process is set. This situation occurs immediately + # after a new `ProcessManager` instance is created and before its initialization is finalized. + # Once initialization is complete, `current_process` is guaranteed to be set, thus the property + # is non-Optional to eliminate the need for redundant `None` checks. The test ensures the property + # enforces this contract by throwing the expected exception when accessed prematurely. + process_manager = ProcessManager(tmp_path) + with pytest.raises(RuntimeError, match='^Current process is not set$'): + process_manager.current_process # pylint: disable=pointless-statement - # Create a new process object with a different PID value - # (it is not important for the test do we have an actual process with this PID value or not) - p2 = TriblerProcess.current_process(ProcessKind.Core, manager=process_manager) - p2.pid += 1 - # The new process should not be able to become a primary process, as we already have the primary process in the DB - assert not p2.become_primary() - assert not p2.primary + x = Mock() + process_manager._current_process = x + assert process_manager.current_process is x - with process_manager.connect() as connection: - # Here we are emulating the situation that the current process abnormally terminated without updating the row - # in the database. To emulate it, we update the `started_at` time of the primary process in the DB. - # After the update, it looks like the actual process with the PID of the primary process (that is, the process - # from which the test suite is running) was created 100 days after the row was added to the database. +def test_save(process_manager: ProcessManager): + # Tests that saving a new process to the database correctly updates its `rowid` attribute. + p = TriblerProcess.current_process(process_manager, ProcessKind.Core, owns_lock=False) + p.pid = p.pid + 100 + assert p.rowid is None + p.save() + assert p.rowid is not None - # As a result, TriblerProcess.is_running() returns False for the previous primary process because it - # believes the running process with the same PID is a new process, different from the process in the DB - connection.execute('update processes set started_at = started_at - (60 * 60 * 24 * 100) where "primary" = 1') - p3 = TriblerProcess.current_process(ProcessKind.Core, manager=process_manager) - p3.pid += 2 - # Now p3 can become a new primary process, because the previous primary process considered - # already finished and replaced with a new unrelated process with the same PID - assert p3.become_primary() - assert p3.primary +@patch('tribler.core.utilities.process_manager.process.TriblerProcess.is_running') +def test_get_primary_process_1(is_running: MagicMock, tmp_path): + # Test that `get_primary_process()` returns None when no processes are present in the database. + pm = ProcessManager(tmp_path) - with process_manager.connect() as connection: - rows = connection.execute('select rowid from processes where "primary" = 1').fetchall() - # At the end, the DB should contain only one primary process, namely p3 - assert len(rows) == 1 and rows[0][0] == p3.rowid + # Validate that no primary processes are returned for both `ProcessKind` options in an empty database scenario. + assert pm.get_primary_process(ProcessKind.Core) is None + assert pm.get_primary_process(ProcessKind.GUI) is None + # Ensure that the `TriblerProcess.is_running()` method was not called + # since no primary process entries were found in the database. + is_running.assert_not_called() -def test_save(process_manager: ProcessManager): - p = TriblerProcess.current_process(ProcessKind.Core, manager=process_manager) - p.pid = p.pid + 100 - p.save() - assert p.rowid is not None + +def _save_core_process(manager: ProcessManager, pid: int, primary: bool) -> TriblerProcess: + process = TriblerProcess(manager=manager, kind=ProcessKind.Core, pid=pid, primary=primary, app_version='1', + started_at=int(time.time()) - 1) + process.save() + return process + + +@patch('tribler.core.utilities.process_manager.process.TriblerProcess.is_running') +def test_get_primary_process_2(is_running: MagicMock, tmp_path): + # Verify `get_primary_process` returns None when the database only contains non-primary processes. + pm = ProcessManager(tmp_path) + + # Populate the database with non-primary Core processes. + _save_core_process(pm, pid=100, primary=False) + _save_core_process(pm, pid=200, primary=False) + + # Assert no primary process is found for both Core and GUI process kinds. + assert pm.get_primary_process(ProcessKind.Core) is None + assert pm.get_primary_process(ProcessKind.GUI) is None + + # Confirm that `is_running` was not called due to the absence of primary process entries. + is_running.assert_not_called() + + +@patch('tribler.core.utilities.process_manager.process.TriblerProcess.is_running', side_effect=[True]) +def test_get_primary_process_3(is_running: MagicMock, tmp_path): + # Test that `get_primary_process` correctly retrieves the primary process for a specified kind. + pm = ProcessManager(tmp_path) + + # Set up the database with several non-primary and one primary Core processes. + _save_core_process(pm, pid=100, primary=False) + _save_core_process(pm, pid=200, primary=False) + p3 = _save_core_process(pm, pid=300, primary=True) + + # Retrieve the primary Core process and assert that it matches the expected process. + primary_process = pm.get_primary_process(ProcessKind.Core) + assert primary_process and primary_process.pid == p3.pid + + # Confirm that `is_running` check was called for the process retrieved from the DB before returning it + is_running.assert_called_once() + + # Verify no primary GUI process is retrieved when only primary process of a different kind is in the database. + assert pm.get_primary_process(ProcessKind.GUI) is None + + +@patch('tribler.core.utilities.process_manager.process.TriblerProcess.is_running', side_effect=[False]) +def test_get_primary_process_4(is_running: MagicMock, tmp_path): + # Test that `get_primary_process` returns None when the process marked as primary in the DB is no longer running + pm = ProcessManager(tmp_path) + + # Add a single primary Core process to the database for which process.is_running() returns False + _save_core_process(pm, pid=100, primary=False) + _save_core_process(pm, pid=200, primary=False) + _save_core_process(pm, pid=300, primary=True) + + assert pm.get_primary_process(ProcessKind.Core) is None # No primary processes should be returned from the DB + # Checks that the previous primary process was successfully selected from the DB, but it is not running anymore + is_running.assert_called() + + last_processes = pm.get_last_processes() + assert len(last_processes) == 3 + + # Verifies that the last call of `get_primary_process` update the state of the last process to make it non-primary + assert last_processes[-1].pid == 300 and not last_processes[-1].primary + + +@patch('tribler.core.utilities.process_manager.process.TriblerProcess.is_running', side_effect=[True, True]) +def test_get_primary_process_5(is_running: MagicMock, tmp_path): + # Verifies that when two processes of the same kind are specified as primary in the database and actually running, + # (an incorrect situation that should never happen), then one process should be returned from the + # `get_primary_process` call and the error "Multiple primary processes found in the database" should be specified + # for all such processes in the database. + pm = ProcessManager(tmp_path) + now = int(time.time()) + + # Incorrect situation, two primary Core processes in the DB, one of them is returned + p1 = TriblerProcess(manager=pm, kind=ProcessKind.Core, pid=100, primary=False, app_version='1', started_at=now-3) + p2 = TriblerProcess(manager=pm, kind=ProcessKind.Core, pid=200, primary=True, app_version='1', started_at=now-2) + p3 = TriblerProcess(manager=pm, kind=ProcessKind.Core, pid=300, primary=True, app_version='1', started_at=now-1) + p1.save() + p2.save() + p3.save() + + p = pm.get_primary_process(ProcessKind.Core) + assert p.pid == 200 # When multiple primary processes are found in the database, the first one is returned + assert is_running.call_count == 2 # For all retrieved primary processes `is_running` check should be performed + + last_processes = pm.get_last_processes() + assert len(last_processes) == 3 + + msg = "Multiple primary processes found in the database" + # Two last processes in the database should have the specified error message + assert [p.error_msg for p in last_processes] == [None, msg, msg] + + +@patch('tribler.core.utilities.process_manager.process.TriblerProcess.is_running', return_value=True) +def test_setup_current_process(is_running: MagicMock, tmp_path): # pylint: disable=unused-argument + pm = ProcessManager(tmp_path) + now = int(time.time()) + + # Add a primary Core process to the database for which process.is_running() returns True + p1 = TriblerProcess(manager=pm, kind=ProcessKind.Core, pid=100, primary=True, app_version='1', started_at=now-1) + p1.save() + + with pytest.raises(RuntimeError, match="^Previous primary process still active: .* Current process: .*"): + pm.setup_current_process(kind=ProcessKind.Core, owns_lock=True) def test_set_api_port(process_manager: ProcessManager): @@ -78,9 +181,9 @@ def test_get_last_processes(process_manager: ProcessManager): last_processes = process_manager.get_last_processes() assert len(last_processes) == 1 and last_processes[0].rowid == process_manager.current_process.rowid - fake_process = TriblerProcess.current_process(ProcessKind.Core, manager=process_manager) + fake_process = TriblerProcess.current_process(manager=process_manager, kind=ProcessKind.Core, owns_lock=False) fake_process.pid = fake_process.pid + 1 - fake_process.become_primary() + fake_process.save() last_processes = process_manager.get_last_processes() assert len(last_processes) == 2 @@ -96,9 +199,9 @@ def test_corrupted_database(logger_exception: Mock, logger_warning: Mock, proces process_manager.db_filepath.write_bytes(db_content[:1500]) # corrupt the database file # no exception, the database is silently re-created: - current_process = TriblerProcess.current_process(ProcessKind.Core, manager=process_manager) - process_manager2 = ProcessManager(process_manager.root_dir, current_process) - current_process.become_primary() + process_manager2 = ProcessManager(process_manager.root_dir) + process_manager2.setup_current_process(kind=ProcessKind.Core, owns_lock=False) + assert logger_exception.call_args[0][0] == 'DatabaseError: database disk image is malformed' assert logger_warning.call_args[0][0] == 'Retrying after the error: DatabaseError: database disk image is malformed' diff --git a/src/tribler/core/utilities/process_manager/tests/test_process.py b/src/tribler/core/utilities/process_manager/tests/test_process.py index 58568bebca6..3fdf5909d0a 100644 --- a/src/tribler/core/utilities/process_manager/tests/test_process.py +++ b/src/tribler/core/utilities/process_manager/tests/test_process.py @@ -10,7 +10,7 @@ def test_tribler_process(): - p = TriblerProcess.current_process(ProcessKind.Core, 123, manager=Mock()) + p = TriblerProcess.current_process(manager=Mock(), kind=ProcessKind.Core, owns_lock=False, creator_pid=123) assert p.is_current_process() assert p.is_running() @@ -28,14 +28,6 @@ def test_tribler_process(): assert re.match(pattern, str(p)) -@pytest.fixture(name='manager') -def manager_fixture(tmp_path: Path) -> ProcessManager: - current_process = TriblerProcess.current_process(ProcessKind.Core) - process_manager = ProcessManager(tmp_path, current_process) - process_manager.connection = Mock() - return process_manager - - @pytest.fixture(name='current_process') def current_process_fixture(process_manager): process_manager.connection = Mock() diff --git a/src/tribler/core/utilities/tests/test_process_locking.py b/src/tribler/core/utilities/tests/test_process_locking.py new file mode 100644 index 00000000000..663a5be4ba3 --- /dev/null +++ b/src/tribler/core/utilities/tests/test_process_locking.py @@ -0,0 +1,20 @@ +from unittest.mock import patch + +from filelock import Timeout + +from tribler.core.utilities.process_locking import try_acquire_file_lock + + +def test_try_acquire_file_lock(tmp_path): + lockfile_path = tmp_path / 'lockfile.lock' + lock = try_acquire_file_lock(lockfile_path) + assert lock.is_locked + lock.release() + assert not lock.is_locked + + +@patch('filelock.FileLock.acquire', side_effect=[Timeout('lockfile-name')]) +def test_try_acquire_file_lock_blocked(acquire, tmp_path): # pylint: disable=unused-argument + lockfile_path = tmp_path / 'lockfile.lock' + lock = try_acquire_file_lock(lockfile_path) + assert lock is None diff --git a/src/tribler/core/utilities/tiny_tribler_service.py b/src/tribler/core/utilities/tiny_tribler_service.py index 0c1b42118a2..62578827f60 100644 --- a/src/tribler/core/utilities/tiny_tribler_service.py +++ b/src/tribler/core/utilities/tiny_tribler_service.py @@ -4,13 +4,16 @@ from pathlib import Path from typing import List, Optional +from filelock import FileLock + from tribler.core.components.component import Component from tribler.core.components.session import Session from tribler.core.config.tribler_config import TriblerConfig from tribler.core.utilities.async_group.async_group import AsyncGroup from tribler.core.utilities.osutils import get_root_state_directory -from tribler.core.utilities.process_manager import ProcessKind, ProcessManager, TriblerProcess, \ - set_global_process_manager +from tribler.core.utilities.process_locking import CORE_LOCK_FILENAME, try_acquire_file_lock +from tribler.core.utilities.process_manager import ProcessKind, ProcessManager +from tribler.core.utilities.process_manager.manager import setup_process_manager from tribler.core.utilities.utilities import make_async_loop_fragile @@ -24,6 +27,7 @@ def __init__(self, components: List[Component], timeout_in_sec=None, state_dir=P self.logger = logging.getLogger(self.__class__.__name__) self.session = None + self.process_lock: Optional[FileLock] = None self.process_manager: Optional[ProcessManager] = None self.config = TriblerConfig(state_dir=state_dir.absolute()) self.timeout_in_sec = timeout_in_sec @@ -72,13 +76,14 @@ async def _start_session(self): def _check_already_running(self): self.logger.info(f'Check if we are already running a Tribler instance in: {self.config.state_dir}') - root_state_dir = get_root_state_directory(create=True) - current_process = TriblerProcess.current_process(ProcessKind.Core) - self.process_manager = ProcessManager(root_state_dir, current_process) - set_global_process_manager(self.process_manager) - if not self.process_manager.current_process.become_primary(): + self.process_lock = try_acquire_file_lock(root_state_dir / CORE_LOCK_FILENAME) + current_process_owns_lock = bool(self.process_lock) + + self.process_manager = setup_process_manager(root_state_dir, ProcessKind.Core, current_process_owns_lock) + + if not current_process_owns_lock: msg = 'Another Core process is already running' self.logger.warning(msg) self.process_manager.sys_exit(1, msg) diff --git a/src/tribler/gui/start_gui.py b/src/tribler/gui/start_gui.py index 76f9b221723..26f07d50c41 100644 --- a/src/tribler/gui/start_gui.py +++ b/src/tribler/gui/start_gui.py @@ -1,6 +1,7 @@ import logging import os import sys +from pathlib import Path from typing import Optional from PyQt5.QtCore import QSettings @@ -13,9 +14,9 @@ ) from tribler.core.logger.logger import load_logger_config from tribler.core.sentry_reporter.sentry_reporter import SentryStrategy -from tribler.core.utilities.process_manager import ProcessKind, ProcessManager, TriblerProcess, \ - set_global_process_manager -from tribler.core.utilities.rest_utils import path_to_url +from tribler.core.utilities.process_locking import GUI_LOCK_FILENAME, try_acquire_file_lock +from tribler.core.utilities.process_manager import ProcessKind +from tribler.core.utilities.process_manager.manager import setup_process_manager from tribler.core.utilities.utilities import show_system_popup from tribler.gui import gui_sentry_reporter from tribler.gui.app_manager import AppManager @@ -26,7 +27,7 @@ logger = logging.getLogger(__name__) -def run_gui(api_port: Optional[int], api_key: Optional[str], root_state_dir, parsed_args): +def run_gui(api_port: Optional[int], api_key: Optional[str], root_state_dir: Path, parsed_args): logger.info(f"Running GUI in {'gui_test_mode' if parsed_args.gui_test_mode else 'normal mode'}") # Workaround for macOS Big Sur, see https://github.com/Tribler/tribler/issues/5728 @@ -38,12 +39,12 @@ def run_gui(api_port: Optional[int], api_key: Optional[str], root_state_dir, par logger.info('Enabling a workaround for Ubuntu 21.04+ wayland environment') os.environ["GDK_BACKEND"] = "x11" - current_process = TriblerProcess.current_process(ProcessKind.GUI) - process_manager = ProcessManager(root_state_dir, current_process) - set_global_process_manager(process_manager) # to be able to add information about exception to the process info - current_process_is_primary = process_manager.current_process.become_primary() + process_lock = try_acquire_file_lock(root_state_dir / GUI_LOCK_FILENAME) + current_process_owns_lock = bool(process_lock) - load_logger_config('tribler-gui', root_state_dir, current_process_is_primary) + process_manager = setup_process_manager(root_state_dir, ProcessKind.GUI, current_process_owns_lock) + + load_logger_config('tribler-gui', root_state_dir, current_process_owns_lock) # Enable tracer using commandline args: --trace-debug or --trace-exceptions trace_logger = check_and_enable_code_tracing('gui', root_state_dir) @@ -55,7 +56,7 @@ def run_gui(api_port: Optional[int], api_key: Optional[str], root_state_dir, par try: app_name = os.environ.get('TRIBLER_APP_NAME', 'triblerapp') - app = TriblerApplication(app_name, sys.argv, start_local_server=current_process_is_primary) + app = TriblerApplication(app_name, sys.argv, start_local_server=current_process_owns_lock) app_manager = AppManager(app) # Note (@ichorid): translator MUST BE created and assigned to a separate variable @@ -64,7 +65,7 @@ def run_gui(api_port: Optional[int], api_key: Optional[str], root_state_dir, par translator = get_translator(settings.value('translation', None)) app.installTranslator(translator) - if not current_process_is_primary: + if not current_process_owns_lock: logger.info('GUI Application is already running.') app.send_torrent_file_path_to_primary_process() logger.info('Close the current GUI application.') diff --git a/src/tribler/gui/tests/test_gui.py b/src/tribler/gui/tests/test_gui.py index 5056ee925f7..260a3668427 100644 --- a/src/tribler/gui/tests/test_gui.py +++ b/src/tribler/gui/tests/test_gui.py @@ -41,10 +41,10 @@ def fixture_window(tmp_path_factory): api_key = hexlify(os.urandom(16)) root_state_dir = tmp_path_factory.mktemp('tribler_state_dir') - current_process = TriblerProcess.current_process(ProcessKind.GUI) - process_manager = ProcessManager(root_state_dir, current_process) - is_primary_process = process_manager.current_process.become_primary() - app = TriblerApplication("triblerapp-guitest", sys.argv, start_local_server=is_primary_process) + process_manager = ProcessManager(root_state_dir) + process_manager.setup_current_process(kind=ProcessKind.GUI, owns_lock=False) + + app = TriblerApplication("triblerapp-guitest", sys.argv, start_local_server=False) app_manager = AppManager(app) # We must create a separate instance of QSettings and clear it. # Otherwise, previous runs of the same app will affect this run.