diff --git a/src/run_tribler.py b/src/run_tribler.py index e72a31f64b2..bfad138a5d9 100644 --- a/src/run_tribler.py +++ b/src/run_tribler.py @@ -83,6 +83,8 @@ def init_boot_logger(): logger.info(f'Root state dir: {root_state_dir}') api_port = os.environ.get('CORE_API_PORT') + api_port = int(api_port) if api_port else None + api_key = os.environ.get('CORE_API_KEY') # Check whether we need to start the core or the user interface diff --git a/src/tribler/core/components/reporter/exception_handler.py b/src/tribler/core/components/reporter/exception_handler.py index d5c45db38c4..1c6dcf91f28 100644 --- a/src/tribler/core/components/reporter/exception_handler.py +++ b/src/tribler/core/components/reporter/exception_handler.py @@ -10,6 +10,7 @@ from tribler.core.components.component import ComponentStartupException from tribler.core.components.reporter.reported_error import ReportedError from tribler.core.sentry_reporter.sentry_reporter import SentryReporter +from tribler.core.utilities.process_manager import get_global_process_manager # There are some errors that we are ignoring. IGNORED_ERRORS_BY_CODE = { @@ -77,6 +78,7 @@ def unhandled_error_observer(self, _, context): It broadcasts the tribler_exception event. """ self.logger.info('Processing unhandled error...') + process_manager = get_global_process_manager() try: self.sentry_reporter.ignore_logger(self.logger.name) @@ -111,6 +113,8 @@ def unhandled_error_observer(self, _, context): should_stop=should_stop ) self.logger.error(f"Unhandled exception occurred! {reported_error}\n{reported_error.long_text}") + if process_manager: + process_manager.current_process.set_error(exception) if self.report_callback: self.logger.error('Call report callback') @@ -123,6 +127,9 @@ def unhandled_error_observer(self, _, context): self.unreported_error = reported_error except Exception as ex: + if process_manager: + process_manager.current_process.set_error(ex) + self.sentry_reporter.capture_exception(ex) self.logger.exception(f'Error occurred during the error handling: {ex}') raise ex diff --git a/src/tribler/core/components/restapi/rest/rest_manager.py b/src/tribler/core/components/restapi/rest/rest_manager.py index fdd86cb0621..12a08551048 100644 --- a/src/tribler/core/components/restapi/rest/rest_manager.py +++ b/src/tribler/core/components/restapi/rest/rest_manager.py @@ -17,6 +17,7 @@ ) from tribler.core.components.restapi.rest.root_endpoint import RootEndpoint from tribler.core.components.restapi.rest.settings import APISettings +from tribler.core.utilities.process_manager import get_global_process_manager from tribler.core.version import version_id logger = logging.getLogger(__name__) @@ -86,6 +87,13 @@ def __init__(self, config: APISettings, root_endpoint: RootEndpoint, state_dir=N def get_endpoint(self, name): return self.root_endpoint.endpoints.get('/' + name) + def set_api_port(self, api_port: int): + if self.config.http_port != api_port: + self.config.http_port = api_port + process_manager = get_global_process_manager() + if process_manager: + process_manager.current_process.set_api_port(api_port) + async def start(self): """ Starts the HTTP API with the listen port as specified in the session configuration. @@ -124,6 +132,7 @@ async def start(self): api_port = self.config.http_port if not self.config.retry_port: self.site = web.TCPSite(self.runner, self.http_host, api_port) + self.set_api_port(api_port) await self.site.start() else: bind_attempts = 0 @@ -131,7 +140,7 @@ async def start(self): try: self.site = web.TCPSite(self.runner, self.http_host, api_port + bind_attempts) await self.site.start() - self.config.http_port = api_port + bind_attempts + self.set_api_port(api_port + bind_attempts) break except OSError: bind_attempts += 1 diff --git a/src/tribler/core/components/session.py b/src/tribler/core/components/session.py index 1b98c7e5d37..9e8621676b5 100644 --- a/src/tribler/core/components/session.py +++ b/src/tribler/core/components/session.py @@ -27,7 +27,7 @@ class Session: def __init__(self, config: TriblerConfig = None, components: List[Component] = (), shutdown_event: Event = None, notifier: Notifier = None, failfast: bool = True): # deepcode ignore unguarded~next~call: not necessary to catch StopIteration on infinite iterator - self.exit_code = 0 + self.exit_code = None self.failfast = failfast self.logger = logging.getLogger(self.__class__.__name__) self.config: TriblerConfig = config or TriblerConfig() diff --git a/src/tribler/core/exceptions.py b/src/tribler/core/exceptions.py index f6ae113d158..d4dd470df79 100644 --- a/src/tribler/core/exceptions.py +++ b/src/tribler/core/exceptions.py @@ -8,9 +8,6 @@ class TriblerException(Exception): """Super class for all Tribler-specific Exceptions the Tribler Core throws.""" - def __str__(self): - return str(self.__class__) + ': ' + Exception.__str__(self) - class OperationNotPossibleAtRuntimeException(TriblerException): """The requested operation is not possible after the Session or Download has been started.""" diff --git a/src/tribler/core/logger/logger.py b/src/tribler/core/logger/logger.py index f603fa8027e..bd1c1e1091f 100644 --- a/src/tribler/core/logger/logger.py +++ b/src/tribler/core/logger/logger.py @@ -16,13 +16,21 @@ def filter(self, record): return record.levelno < logging.ERROR -def load_logger_config(app_mode, log_dir): +def load_logger_config(app_mode, log_dir, current_process_is_primary=True): """ Loads tribler-gui module logger configuration. Note that this function should be called explicitly to enable GUI logs dump to a file in the log directory (default: inside state directory). """ - logger_config_path = get_logger_config_path() - setup_logging(app_mode, Path(log_dir), logger_config_path) + if current_process_is_primary: + # Set up logging to files for primary process only, as logging module does not support + # writing to the same log file from multiple Python processes + logger_config_path = get_logger_config_path() + setup_logging(app_mode, Path(log_dir), logger_config_path) + else: + logger.info('Skip the initialization of a normal file-based logging as the current process is non-primary.\n' + 'Continue using the basic logging config from the boot logger initialization.\n' + 'Only primary Tribler process can write to Tribler log files, as logging module does not support\n' + 'writing to files from multiple Python processes.') def get_logger_config_path(): diff --git a/src/tribler/core/sentry_reporter/sentry_reporter.py b/src/tribler/core/sentry_reporter/sentry_reporter.py index e94724b8de9..319ad9aa882 100644 --- a/src/tribler/core/sentry_reporter/sentry_reporter.py +++ b/src/tribler/core/sentry_reporter/sentry_reporter.py @@ -147,7 +147,8 @@ def add_breadcrumb(self, message='', category='', level='info', **kwargs): return sentry_sdk.add_breadcrumb(crumb, **kwargs) def send_event(self, event: Dict = None, post_data: Dict = None, sys_info: Dict = None, - additional_tags: List[str] = None, last_core_output: Optional[str] = None): + additional_tags: List[str] = None, last_core_output: Optional[str] = None, + last_processes: List[str] = None): """Send the event to the Sentry server This method @@ -168,6 +169,7 @@ def send_event(self, event: Dict = None, post_data: Dict = None, sys_info: Dict sys_info: dictionary made by the feedbackdialog.py additional_tags: tags that will be added to the event last_core_output: string that represents last core output + last_processes: list of strings describing last Tribler GUI/Core processes Returns: Event that was sent to Sentry server @@ -219,6 +221,9 @@ def send_event(self, event: Dict = None, post_data: Dict = None, sys_info: Dict reporter['events'] = extract_dict(sys_info, r'^(event|request)') reporter[SYSINFO] = {key: sys_info[key] for key in sys_info if key not in reporter['events']} + if last_processes: + reporter['last_processes'] = last_processes + # try to retrieve an error from the last_core_output if last_core_output: # split for better representation in the web view diff --git a/src/tribler/core/start_core.py b/src/tribler/core/start_core.py index 5ad24591f23..0ee3db3d213 100644 --- a/src/tribler/core/start_core.py +++ b/src/tribler/core/start_core.py @@ -16,6 +16,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 import GuiProcessWatcher 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 @@ -38,7 +39,8 @@ from tribler.core.logger.logger import load_logger_config from tribler.core.sentry_reporter.sentry_reporter import SentryReporter, SentryStrategy from tribler.core.upgrade.version_manager import VersionHistory -from tribler.core.utilities.process_checker import single_tribler_instance +from tribler.core.utilities.process_manager import ProcessKind, ProcessManager, TriblerProcess, \ + set_global_process_manager logger = logging.getLogger(__name__) CONFIG_FILE_NAME = 'triblerd.conf' @@ -119,7 +121,7 @@ async def core_session(config: TriblerConfig, components: List[Component]) -> in return session.exit_code -def run_tribler_core_session(api_port: str, api_key: str, state_dir: Path, gui_test_mode: bool = False) -> int: +def run_tribler_core_session(api_port: int, api_key: str, state_dir: Path, gui_test_mode: bool = False) -> int: """ This method will start a new Tribler session. Note that there is no direct communication between the GUI process and the core: all communication is performed @@ -137,7 +139,7 @@ def run_tribler_core_session(api_port: str, api_key: str, state_dir: Path, gui_t if SentryReporter.is_in_test_mode(): default_core_exception_handler.sentry_reporter.global_strategy = SentryStrategy.SEND_ALLOWED - config.api.http_port = int(api_port) + config.api.http_port = api_port # If the API key is set to an empty string, it will remain disabled if config.api.key not in ('', api_key): config.api.key = api_key @@ -174,13 +176,27 @@ def run_tribler_core_session(api_port: str, api_key: str, state_dir: Path, gui_t def run_core(api_port, api_key, root_state_dir, parsed_args): - logger.info('Running Core' + ' in gui_test_mode' if parsed_args.gui_test_mode else '') - load_logger_config('tribler-core', root_state_dir) - - with single_tribler_instance(root_state_dir): - version_history = VersionHistory(root_state_dir) - state_dir = version_history.code_version.directory - exit_code = run_tribler_core_session(api_port, api_key, state_dir, gui_test_mode=parsed_args.gui_test_mode) - - if exit_code: - sys.exit(exit_code) + 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() + + load_logger_config('tribler-core', root_state_dir, current_process_is_primary) + + if not current_process_is_primary: + msg = 'Another Core process is already running' + logger.warning(msg) + process_manager.sys_exit(1, msg) + + if api_port is None: + msg = 'api_port is not specified for a core process' + logger.error(msg) + process_manager.sys_exit(1, msg) + + version_history = VersionHistory(root_state_dir) + state_dir = version_history.code_version.directory + exit_code = run_tribler_core_session(api_port, api_key, state_dir, gui_test_mode=parsed_args.gui_test_mode) + process_manager.sys_exit(exit_code) diff --git a/src/tribler/core/utilities/process_checker.py b/src/tribler/core/utilities/process_checker.py deleted file mode 100644 index 07583761242..00000000000 --- a/src/tribler/core/utilities/process_checker.py +++ /dev/null @@ -1,169 +0,0 @@ -from __future__ import annotations - -import logging -import os -import re -import sys -from contextlib import contextmanager -from typing import Iterable, Optional - -import psutil - -from tribler.core.utilities.path_util import Path - - -LOCK_FILE_NAME = 'triblerd.lock' - - -@contextmanager -def single_tribler_instance(directory: Path): - checker = ProcessChecker(directory) - try: - checker.check_and_restart_if_necessary() - checker.create_lock() - yield checker - finally: - checker.remove_lock() - - -class ProcessChecker: - """ - This class contains code to check whether a Tribler process is already running. - """ - - def __init__(self, directory: Path, lock_file_name: Optional[str] = None): - lock_file_name = lock_file_name or LOCK_FILE_NAME - self.lock_file = directory / lock_file_name - self.logger = logging.getLogger(self.__class__.__name__) - self.logger.info(f'Lock file: {self.lock_file}') - self.re_tribler = re.compile(r'tribler\b(?![/\\])') - - def check_and_restart_if_necessary(self) -> bool: - self.logger.info('Check') - - pid = self._get_pid_from_lock() - try: - process = psutil.Process(pid) - status = process.status() - except psutil.Error as e: - self.logger.warning(e) - return False - - if not self._is_old_tribler_process_running(process): - return False - - if status == psutil.STATUS_ZOMBIE: - self._close_process(process) - self._restart_tribler() - return True - - self._ask_to_restart(process) - return True - - def create_lock(self, pid: Optional[int] = None): - self.logger.info('Create the lock file') - - pid = pid or os.getpid() - try: - self.lock_file.parent.mkdir(exist_ok=True) - self.lock_file.write_text(f'{pid}') - except Exception as e: # pylint: disable=broad-except - self.logger.exception(e) - - def remove_lock(self): - self.logger.info('Remove the lock file') - - try: - self.lock_file.unlink(missing_ok=True) - except Exception as e: # pylint: disable=broad-except - self.logger.exception(e) - - def _get_pid_from_lock(self) -> Optional[int]: - """ - Returns the PID from the lock file. - """ - self.logger.info('Get PID from the lock file') - try: - pid = int(self.lock_file.read_text()) - self.logger.info(f'PID is {pid}') - return pid - except Exception as e: # pylint: disable=broad-except - self.logger.warning(e) - - return None - - def _is_tribler_cmd(self, cmd_line: Optional[Iterable[str]]) -> bool: - cmd_line = cmd_line or [] - cmd = ''.join(cmd_line).lower() - self.logger.info(f'Check process cmd: {cmd}') - - return self.re_tribler.search(cmd) is not None - - def _is_old_tribler_process_running(self, process: psutil.Process) -> bool: - cmdline = process.as_dict()['cmdline'] - - has_keyword = self._is_tribler_cmd(cmdline) - pid_is_exists = psutil.pid_exists(process.pid) - pid_is_correct = process.pid > 1 and process.pid != os.getpid() - - result = has_keyword and pid_is_exists and pid_is_correct - self.logger.info(f'Result: {result} (has_keyword={has_keyword}, ' - f'pid_is_exists={pid_is_exists}, pid_is_correct={pid_is_correct})') - - return result - - def _ask_to_restart(self, process: psutil.Process): - self.logger.info('Ask to restart') - - try: - self._close_process(process) - - from PyQt5.QtWidgets import QApplication, QMessageBox # pylint: disable=import-outside-toplevel - _ = QApplication(sys.argv) - message_box = QMessageBox() - message_box.setWindowTitle("Warning") - message_box.setText("Warning") - message_box.setInformativeText( - f"An existing Tribler core process (PID:{process.pid}) is already running. \n\n" - f"Do you want to stop the process and do a clean restart instead?" - ) - message_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) - message_box.setDefaultButton(QMessageBox.Save) - result = message_box.exec_() - if result == QMessageBox.Yes: - self.logger.info('Ask to restart (yes)') - self._restart_tribler() - except Exception as e: # pylint: disable=broad-except - self.logger.exception(e) - - def _close_process(self, process: psutil.Process): - def close_handlers(): - for handler in process.open_files() + process.connections(): - self.logger.info(f'OS close: {handler}') - try: - os.close(handler.fd) - except Exception as e: # pylint: disable=broad-except - self.logger.warning(e) - - def kill_processes(): - processes_to_kill = [process, process.parent()] - self.logger.info(f'Kill Tribler processes: {processes_to_kill}') - for p in processes_to_kill: - try: - if self._is_old_tribler_process_running(p): - self.logger.info(f'Kill: {p.pid}') - os.kill(p.pid, 9) - except OSError as e: - self.logger.exception(e) - - close_handlers() - kill_processes() - - def _restart_tribler(self): - """ Restart Tribler - """ - self.logger.info('Restart Tribler') - - python = sys.executable - self.logger.info(f'OS execl: "{python}". Args: "{sys.argv}"') - os.execl(python, python, *sys.argv) # See: https://github.com/Tribler/tribler/issues/6948 diff --git a/src/tribler/core/utilities/process_manager/__init__.py b/src/tribler/core/utilities/process_manager/__init__.py new file mode 100644 index 00000000000..b117f4e2f33 --- /dev/null +++ b/src/tribler/core/utilities/process_manager/__init__.py @@ -0,0 +1,3 @@ +from tribler.core.utilities.process_manager.process import ProcessKind, TriblerProcess +from tribler.core.utilities.process_manager.manager import get_global_process_manager, ProcessManager,\ + set_global_process_manager diff --git a/src/tribler/core/utilities/process_manager/manager.py b/src/tribler/core/utilities/process_manager/manager.py new file mode 100644 index 00000000000..031913247fe --- /dev/null +++ b/src/tribler/core/utilities/process_manager/manager.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import logging +import sqlite3 +import sys +from pathlib import Path +from threading import Lock +from typing import ContextManager, List, Optional + +from contextlib import contextmanager + +from tribler.core.utilities.process_manager import sql_scripts +from tribler.core.utilities.process_manager.process import ProcessKind, TriblerProcess +from tribler.core.utilities.process_manager.utils import with_retry + +logger = logging.getLogger(__name__) + +DB_FILENAME = 'processes.sqlite' + + +global_process_manager: Optional[ProcessManager] = None + +_lock = Lock() + + +def set_global_process_manager(process_manager: Optional[ProcessManager]): + global global_process_manager # pylint: disable=global-statement + with _lock: + global_process_manager = process_manager + + +def get_global_process_manager() -> Optional[ProcessManager]: + with _lock: + return global_process_manager + + +class ProcessManager: + def __init__(self, root_dir: Path, current_process: TriblerProcess, 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 + + @contextmanager + def connect(self) -> ContextManager[sqlite3.Connection]: + """ + A context manager opens a connection to the database and handles the transaction. + + The opened connection is stored inside the ProcessManager instance. It allows to recursively + the context manager, the inner invocation re-uses the connection opened in the outer context manager. + + In the case of a sqlite3.DatabaseError exception, the database is deleted to handle possible database + corruption. The database content is not critical for Tribler's functioning, so its loss is tolerable. + """ + + if self.connection is not None: + yield self.connection + return + + connection = None + try: + self.connection = connection = sqlite3.connect(str(self.db_filepath)) + try: + connection.execute('BEGIN EXCLUSIVE TRANSACTION') + connection.execute(sql_scripts.CREATE_TABLES) + connection.execute(sql_scripts.DELETE_OLD_RECORDS) + yield connection + finally: + self.connection = None + connection.execute('COMMIT') + connection.close() + + except Exception as e: + logger.exception(f'{e.__class__.__name__}: {e}') + if connection: + connection.close() + if isinstance(e, sqlite3.DatabaseError): + self.db_filepath.unlink(missing_ok=True) + raise + + def primary_process_rowid(self, kind: ProcessKind) -> Optional[int]: + """ + A helper method to load the current primary process of the specified kind from the database. + + Returns rowid of the existing 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 + """, [kind.value]) + row = cursor.fetchone() + if row is not None: + process = TriblerProcess.from_row(self, row) + if process.is_running(): + return process.rowid + + # Process is not running anymore; mark it as not primary + process.primary = False + process.save() + return None + + def sys_exit(self, exit_code: Optional[int] = None, error: Optional[str | Exception] = None, replace: bool = False): + """ + Calls sys.exit(exit_code) and stores exit code & error information (if provided) to the processes' database. + + Developers should use this method instead of a direct calling of sys.exit(). + """ + process = self.current_process + if error is not None: + process.set_error(error, replace) + process.finish(exit_code) + exit_code = process.exit_code + sys.exit(exit_code) + + @with_retry + def get_last_processes(self, limit=6) -> List[TriblerProcess]: + """ + Returns last `limit` processes from the database. They are used during the formatting of the error report. + """ + with self.connect() as connection: # pylint: disable=not-context-manager # false Pylint alarm + cursor = connection.execute(f""" + SELECT {sql_scripts.SELECT_COLUMNS} + FROM processes ORDER BY rowid DESC LIMIT ? + """, [limit]) + result = [TriblerProcess.from_row(self, row) for row in cursor] + result.reverse() + return result diff --git a/src/tribler/core/utilities/process_manager/process.py b/src/tribler/core/utilities/process_manager/process.py new file mode 100644 index 00000000000..44f5b492af8 --- /dev/null +++ b/src/tribler/core/utilities/process_manager/process.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import logging +import os +import sqlite3 +import time +from datetime import datetime +from enum import Enum +from typing import Optional, TYPE_CHECKING, Union + +import psutil + +from tribler.core.utilities.process_manager.utils import with_retry +from tribler.core.version import version_id + +if TYPE_CHECKING: + from tribler.core.utilities.process_manager import ProcessManager + + +class ProcessKind(Enum): + GUI = 'gui' + Core = 'core' + + +class TriblerProcess: + def __init__(self, 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 + self.rowid = rowid + self.row_version = row_version + self.pid = pid + self.kind = kind + self.primary = primary + self.canceled = canceled + self.app_version = app_version + self.started_at = started_at + self.creator_pid = creator_pid + self.api_port = api_port + self.finished_at = finished_at + 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""" + return self.manager.logger + + @property + def connection(self) -> Optional[sqlite3.Connection]: + """Used by the `with_retry` decorator""" + return self.manager.connection + + @with_retry + def save(self): + """Saves object into the database""" + with self.manager.connect() as connection: + if self.rowid is None: + self._insert(connection) + else: + self._update(connection) + + @classmethod + def from_row(cls, manager: ProcessManager, row: tuple) -> TriblerProcess: + """Constructs an object from the database row""" + rowid, row_version, pid, kind, primary, canceled, app_version, started_at, creator_pid, api_port, \ + finished_at, exit_code, error_msg = row + + return TriblerProcess(manager=manager, rowid=rowid, row_version=row_version, pid=pid, kind=ProcessKind(kind), + primary=primary, canceled=canceled, app_version=app_version, started_at=started_at, + creator_pid=creator_pid, api_port=api_port, finished_at=finished_at, + exit_code=exit_code, error_msg=error_msg) + + def __str__(self) -> str: + kind = self.kind.value.capitalize() + flags = f"{'primary, ' if self.primary else ''}{'canceled, ' if self.canceled else ''}" + result = [f'{kind}Process({flags}pid={self.pid}'] + if self.creator_pid is not None: + result.append(f', gui_pid={self.creator_pid}') + started = datetime.utcfromtimestamp(self.started_at) + result.append(f", version='{self.app_version}', started='{started.strftime('%Y-%m-%d %H:%M:%S')}'") + if self.api_port is not None: + result.append(f', api_port={self.api_port}') + if self.finished_at: + finished = datetime.utcfromtimestamp(self.finished_at) + duration = finished - started + result.append(f", duration='{duration}'") + if self.exit_code is not None: + result.append(f', exit_code={self.exit_code}') + if self.error_msg: + result.append(f', error={repr(self.error_msg)}') + result.append(')') + return ''.join(result) + + @classmethod + def current_process(cls, kind: ProcessKind, + creator_pid: Optional[int] = None, + manager: Optional[ProcessManager] = None) -> TriblerProcess: + """Constructs an object for a current process, specifying the PID value of the current process""" + return cls(manager=manager, row_version=0, pid=os.getpid(), kind=kind, + app_version=version_id, started_at=int(time.time()), 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): + return False + + try: + process = psutil.Process(self.pid) + status = process.status() + except psutil.Error as e: + self.logger.warning(e) + return False + + if status == psutil.STATUS_ZOMBIE: + return False + + if process.create_time() > self.started_at: + return False + + return True + + def set_api_port(self, api_port: int): + self.api_port = api_port + self.save() + + def set_error(self, error: Union[str | Exception], replace: bool = False): + if isinstance(error, Exception): + error = f"{error.__class__.__name__}: {error}" + self.error_msg = error if replace else (self.error_msg or error) + self.save() + + def finish(self, exit_code: Optional[int] = None): + self.primary = False + self.finished_at = int(time.time()) + + # if exit_code is specified, it overrides the previously set exit code + if exit_code is not None: + self.exit_code = exit_code + + # if no exit code is specified, use exit code 0 (success) as a default value + if self.exit_code is None: + self.exit_code = 0 if not self.error_msg else 1 + + self.save() + + def _insert(self, connection: sqlite3.Connection): + """Insert a new row into the table""" + self.row_version = 0 + cursor = connection.cursor() + cursor.execute(""" + INSERT INTO processes ( + pid, kind, "primary", canceled, app_version, started_at, + creator_pid, api_port, finished_at, exit_code, error_msg + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, [self.pid, self.kind.value, int(self.primary), int(self.canceled), self.app_version, self.started_at, + self.creator_pid, self.api_port, self.finished_at, self.exit_code, self.error_msg]) + self.rowid = cursor.lastrowid + + def _update(self, connection: sqlite3.Connection): + """Update an existing row in the table""" + prev_version = self.row_version + self.row_version += 1 + cursor = connection.cursor() + cursor.execute(""" + UPDATE processes + SET row_version = ?, "primary" = ?, canceled = ?, creator_pid = ?, api_port = ?, + finished_at = ?, exit_code = ?, error_msg = ? + WHERE rowid = ? and row_version = ? and pid = ? and kind = ? and app_version = ? and started_at = ? + """, [self.row_version, int(self.primary), int(self.canceled), self.creator_pid, self.api_port, + self.finished_at, self.exit_code, self.error_msg, + self.rowid, prev_version, self.pid, self.kind.value, self.app_version, self.started_at]) + if cursor.rowcount == 0: + self.logger.error(f'Row {self.rowid} with row version {prev_version} was not found') diff --git a/src/tribler/core/utilities/process_manager/sql_scripts.py b/src/tribler/core/utilities/process_manager/sql_scripts.py new file mode 100644 index 00000000000..0fdfdcf0727 --- /dev/null +++ b/src/tribler/core/utilities/process_manager/sql_scripts.py @@ -0,0 +1,31 @@ +CREATE_TABLES = """ + CREATE TABLE IF NOT EXISTS processes ( + rowid INTEGER PRIMARY KEY AUTOINCREMENT, + row_version INTEGER NOT NULL DEFAULT 0, -- incremented every time the row is updated + pid INTEGER NOT NULL, -- process ID + kind TEXT NOT NULL, -- process type, 'core' or 'gui' + "primary" INT NOT NULL, -- 1 means the process is considered to be the "main" process of the specified kind + canceled INT NOT NULL, -- 1 means that another process is already working as primary, so this process is stopped + app_version TEXT NOT NULL, -- the Tribler version + started_at INT NOT NULL, -- unix timestamp of the time when the process was started + creator_pid INT, -- for a Core process this is the pid of the corresponding GUI process + api_port INT, -- Core API port, for GUI process this is a suggested port that Core can use + finished_at INT, -- unix timestamp of the time when the process was finished + exit_code INT, -- for completed process this is the exit code, 0 means successful run without termination + error_msg TEXT -- a description of an exception that possibly led to the process termination + ) +""" + +DELETE_OLD_RECORDS = """ + DELETE FROM processes -- delete all non-primary records that are older than 30 days or not in the 100 last records + WHERE "primary" = 0 -- never delete current primary processes + AND ( + finished_at < strftime('%s') - (60 * 60 * 24) * 30 -- delete record if a process finished more than 30 days ago + OR rowid NOT IN ( + SELECT rowid FROM processes ORDER BY rowid DESC LIMIT 100 -- only keep last 100 processes + ) + ) +""" + +SELECT_COLUMNS = 'rowid, row_version, pid, kind, "primary", canceled, app_version, ' \ + 'started_at, creator_pid, api_port, finished_at, exit_code, error_msg' diff --git a/src/tribler/core/utilities/process_manager/tests/__init__.py b/src/tribler/core/utilities/process_manager/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/tribler/core/utilities/process_manager/tests/conftest.py b/src/tribler/core/utilities/process_manager/tests/conftest.py new file mode 100644 index 00000000000..ad51b460b96 --- /dev/null +++ b/src/tribler/core/utilities/process_manager/tests/conftest.py @@ -0,0 +1,14 @@ +from pathlib import Path + +import pytest + +from tribler.core.utilities.process_manager import ProcessKind, ProcessManager, TriblerProcess + + +@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() + 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 new file mode 100644 index 00000000000..55f109706d2 --- /dev/null +++ b/src/tribler/core/utilities/process_manager/tests/test_manager.py @@ -0,0 +1,139 @@ +import time +from unittest.mock import Mock, patch + +from tribler.core.utilities.process_manager.process import ProcessKind, TriblerProcess +from tribler.core.utilities.process_manager.manager import logger, ProcessManager + + +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 + + # 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 + + 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. + + # 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 + + 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 + + +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 test_set_api_port(process_manager: ProcessManager): + process_manager.current_process.set_api_port(12345) + assert process_manager.current_process.api_port == 12345 + with process_manager.connect() as connection: + rows = connection.execute('select rowid from processes where api_port = 12345').fetchall() + assert len(rows) == 1 and rows[0][0] == process_manager.current_process.rowid + + +@patch('sys.exit') +def test_sys_exit(sys_exit: Mock, process_manager: ProcessManager): + process_manager.sys_exit(123, 'Error text') + + with process_manager.connect() as connection: + rows = connection.execute('select "primary", error_msg from processes where rowid = ?', + [process_manager.current_process.rowid]).fetchall() + assert len(rows) == 1 and rows[0] == (0, 'Error text') + assert sys_exit.called and sys_exit.call_args[0][0] == 123 + + +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.pid = fake_process.pid + 1 + fake_process.become_primary() + + last_processes = process_manager.get_last_processes() + assert len(last_processes) == 2 + assert last_processes[0].rowid == process_manager.current_process.rowid + assert last_processes[1].rowid == fake_process.rowid + + +@patch.object(logger, 'warning') +@patch.object(logger, 'exception') +def test_corrupted_database(logger_exception: Mock, logger_warning: Mock, process_manager: ProcessManager): + db_content = process_manager.db_filepath.read_bytes() + assert len(db_content) > 2000 + 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() + 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' + + processes = process_manager2.get_last_processes() + assert len(processes) == 1 + + +def test_delete_old_records_1(process_manager): + # Let's check that records of processes finished more than 30 days ago are deleted from the database + now = int(time.time()) + day = 60 * 60 * 24 + with process_manager.connect() as connection: + # At that moment we have only the current process + assert connection.execute("select count(*) from processes").fetchone()[0] == 1 + + # Let's add 100 processes finished in previous days + for i in range(1, 101): + p = TriblerProcess(manager=process_manager, pid=i, kind=ProcessKind.Core, app_version='', + started_at=now - day * i - 60, finished_at=now - day * i + 60) + p.save() + assert connection.execute("select count(*) from processes").fetchone()[0] == 101 + + with process_manager.connect() as connection: + # Only the current primary process and processes finished during the last 30 days should remain + assert connection.execute("select count(*) from processes").fetchone()[0] == 31 + + +def test_delete_old_records_2(process_manager): + # Let's check that at most 100 non-primary processes are kept in the database + now = int(time.time()) + with process_manager.connect() as connection: + # At that moment we have only the current process + assert connection.execute("select count(*) from processes").fetchone()[0] == 1 + + # Let's add 200 processes + for i in range(200): + p = TriblerProcess(manager=process_manager, pid=i, kind=ProcessKind.Core, app_version='', + started_at=now - 120) + p.save() + assert connection.execute("select count(*) from processes").fetchone()[0] == 201 + + with process_manager.connect() as connection: + # Only the current primary process and the last 100 processes should remain + assert connection.execute("select count(*) from processes").fetchone()[0] == 101 diff --git a/src/tribler/core/utilities/process_manager/tests/test_process.py b/src/tribler/core/utilities/process_manager/tests/test_process.py new file mode 100644 index 00000000000..3eae8a98075 --- /dev/null +++ b/src/tribler/core/utilities/process_manager/tests/test_process.py @@ -0,0 +1,145 @@ +import re +from pathlib import Path +from unittest.mock import Mock, patch + +import psutil +import pytest + +from tribler.core.utilities.process_manager.manager import ProcessManager, logger +from tribler.core.utilities.process_manager.process import ProcessKind, TriblerProcess + + +def test_tribler_process(): + p = TriblerProcess.current_process(ProcessKind.Core, 123, manager=Mock()) + assert p.is_current_process() + assert p.is_running() + + pattern = r"^CoreProcess\(pid=\d+, gui_pid=123, version='[^']+', started='\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'\)$" + 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() + return process_manager.current_process + + +@patch('psutil.pid_exists') +def test_is_running_pid_does_not_exists(pid_exists: Mock, current_process): + pid_exists.return_value = False + # if the pid does not exist, the process is not running + assert not pid_exists.called + assert current_process.is_running() is False + assert pid_exists.called + + +@patch('psutil.Process') +def test_is_running_process_not_running(process_class: Mock, current_process): + process_class.side_effect = psutil.Error + # if the instantiation of the Process instance lead to psutil.Error, the process is not running + assert current_process.is_running() is False + assert process_class.called + + +@patch('psutil.Process') +def test_is_running_zombie_process(process_class: Mock, current_process): + process_class.return_value.status.return_value = psutil.STATUS_ZOMBIE + # if the process is zombie, it is not considered to be running + assert current_process.is_running() is False + + +@patch('psutil.Process') +def test_is_running_incorrect_process_create_time(process_class: Mock, current_process): + process = process_class.return_value + process.status.return_value = psutil.STATUS_RUNNING + process.create_time.return_value = current_process.started_at + 1 + # if the process with the specified pid was created after the specified time, it is a different process + assert current_process.is_running() is False + + +@patch('psutil.Process') +def test_is_running(process_class: Mock, current_process): + process = process_class.return_value + process.status.return_value = psutil.STATUS_RUNNING + process.create_time.return_value = current_process.started_at + # if the process exists, it is not a zombie, and its creation time matches the recorded value, it is running + assert current_process.is_running() is True + + +def test_tribler_process_set_error(current_process): + assert current_process.error_msg is None + + current_process.set_error('Error text 1') + assert current_process.error_msg == 'Error text 1' + + current_process.set_error('Error text 2') + # By default, the second exception does not override the first one (as the first exception may be the root case) + assert current_process.error_msg == 'Error text 1' + + # But it is possible to override exception explicitly + current_process.set_error('Error text 2', replace=True) + assert current_process.error_msg == 'Error text 2' + + # It is also possible to specify an exception + current_process.set_error(ValueError('exception text'), replace=True) + assert current_process.error_msg == 'ValueError: exception text' + + # The error text is included in ProcessInfo.__str__() output + pattern = r"^CoreProcess\(primary, pid=\d+, version='[^']+', started='[^']+', error='ValueError: exception text'\)$" + assert re.match(pattern, str(current_process)) + + +def test_tribler_process_mark_finished(current_process): + p = current_process # for brevity + assert p.exit_code is None + assert p.finished_at is None + p.primary = True + p.api_port = 10000 + p.finish(123) + assert not p.primary + assert p.exit_code == 123 + assert p.finished_at is not None + assert str(p).endswith(", api_port=10000, duration='0:00:00', exit_code=123)") + + +def test_tribler_process_mark_finished_no_exit_code(current_process): + current_process.finish() # the error is not set and the exit code is not specified, and by default should be 0 + assert current_process.exit_code == 0 + + +def test_tribler_process_mark_finished_error_text(current_process): + current_process.error_msg = 'Error text' + current_process.finish() # the error is set and the exit code is not specified, and by default should be 1 + assert current_process.exit_code == 1 + + +@patch.object(logger, 'error') +def test_tribler_process_save(logger_error: Mock, current_process): + p = current_process # for brevity + + cursor = p.manager.connection.cursor.return_value + cursor.lastrowid = 123 + + p.rowid = None + p.save() + assert "INSERT INTO" in cursor.execute.call_args[0][0] + assert p.rowid == 123 and p.row_version == 0 + + cursor.rowcount = 1 + p.save() + assert "UPDATE" in cursor.execute.call_args[0][0] + assert p.rowid == 123 and p.row_version == 1 + + assert not logger_error.called + cursor.rowcount = 0 + p.save() + assert logger_error.called + assert logger_error.call_args[0][0] == 'Row 123 with row version 1 was not found' diff --git a/src/tribler/core/utilities/process_manager/utils.py b/src/tribler/core/utilities/process_manager/utils.py new file mode 100644 index 00000000000..63f1ec9f986 --- /dev/null +++ b/src/tribler/core/utilities/process_manager/utils.py @@ -0,0 +1,34 @@ +import sqlite3 +from functools import wraps +from logging import Logger +from typing import Optional, Protocol + + +class ClassWithOptionalConnection(Protocol): + connection: Optional[sqlite3.Connection] + logger: Logger + + +def with_retry(method): + """ + This decorator re-runs the wrapped ProcessManager method once in the case of sqlite3.Error` exception. + + This way, it becomes possible to handle exceptions like sqlite3.DatabaseError "database disk image is malformed". + In case of an error, the first function invocation removes the corrupted database file, and the second invocation + re-creates the database structure. The content of the database is not critical for Tribler's functioning, + so it is OK for Tribler to re-create it in such cases. + """ + @wraps(method) + def new_method(self: ClassWithOptionalConnection, *args, **kwargs): + if self.connection: + # If we are already inside transaction just call the function without retrying + return method(self, *args, **kwargs) + + try: + return method(self, *args, **kwargs) + except sqlite3.Error as e: + self.logger.warning(f'Retrying after the error: {e.__class__.__name__}: {e}') + return method(self, *args, **kwargs) + + new_method: method + return new_method diff --git a/src/tribler/core/utilities/tests/test_process_checker.py b/src/tribler/core/utilities/tests/test_process_checker.py deleted file mode 100644 index 3e0266047ed..00000000000 --- a/src/tribler/core/utilities/tests/test_process_checker.py +++ /dev/null @@ -1,229 +0,0 @@ -from unittest.mock import MagicMock, Mock, patch - -import psutil -import pytest -from PyQt5.QtWidgets import QMessageBox - -from tribler.core.utilities.patch_import import patch_import -from tribler.core.utilities.path_util import Path -from tribler.core.utilities.process_checker import ProcessChecker, single_tribler_instance - -TRIBLER_CMD_LINE = [ - ['usr/bin/python', 'run_tribler.py'], - [r'c:\Program Files\Tribler\Tribler.exe'], - [r'c:\Program Files\Tribler\Tribler.exe', 'some.torrent'], - ['Tribler.sh'], - ['Contents/MacOS/tribler'], -] - -NOT_TRIBLER_CMD_LINE = [ - None, - ['usr/bin/python'], - [r'c:\Program Files\Tribler\any.exe'], - [r'tribler\any\path'], -] - - -# pylint: disable=redefined-outer-name, protected-access - -@pytest.fixture -def checker(tmp_path): - return ProcessChecker(directory=Path(tmp_path)) - - -class ProcessMock: - def __init__(self): - self.pid = 42 - self.as_dict = MagicMock(return_value={'cmdline': r'some\path\tribler'}) - self.parent = MagicMock(return_value=MagicMock(pid=32)) - self.open_files = MagicMock() - self.connections = MagicMock() - - -@pytest.fixture -def process(): - return ProcessMock() - - -def test_get_pid_lock_file(checker: ProcessChecker): - # Test that previously saved PID can be read. - checker.lock_file.write_text('42') - assert checker._get_pid_from_lock() == 42 - - -def test_get_wrong_pid_lock_file(checker: ProcessChecker): - # Test that in the case of inconsistent PID None will be returned. - checker.lock_file.write_text('string') - assert checker._get_pid_from_lock() is None - - -@patch.object(Path, 'read_text', Mock(side_effect=PermissionError)) -def test_permission_denied(checker: ProcessChecker): - # Test that in the case of any Exception, None will be returned. - checker.lock_file.write_text('42') - - assert checker._get_pid_from_lock() is None - - -def test_missed_lock_file(checker: ProcessChecker): - # Test that in the case of a missed lock file, None will be returned. - assert checker._get_pid_from_lock() is None - - -def test_is_old_tribler_process_cmdline_none(checker: ProcessChecker, process: ProcessMock): - # Test that in the case of a missed `cmdline`, False will be returned. - assert not checker._is_old_tribler_process_running(process) - - -@patch('psutil.pid_exists', Mock(return_value=True)) -def test_is_old_tribler_process(checker: ProcessChecker, process: ProcessMock): - # Test that in the case keyword 'tribler' is somewhere in `cmdline', True will be returned. - assert checker._is_old_tribler_process_running(process) - - -def test_is_not_old_tribler_process(checker: ProcessChecker, process: ProcessMock): - # Test that in the case keyword 'tribler' is not somewhere in `cmdline', False will be returned. - assert not checker._is_old_tribler_process_running(process) - - -def test_create_lock(checker: ProcessChecker): - # Test that the lock file can be created and read. - assert not checker._get_pid_from_lock() - - checker.create_lock() - - assert isinstance(checker._get_pid_from_lock(), int) - - -def test_create_lock_sub_folder(tmp_path): - # Test that the lock file can be created in a folder that does not exist. - checker = ProcessChecker(directory=tmp_path / 'sub folder') - checker.create_lock() - - assert checker._get_pid_from_lock() - - -@patch.object(Path, 'write_text', Mock(side_effect=PermissionError)) -def test_create_lock_exception(checker: ProcessChecker): - # Test that the lock file can not be created in the case of any Exception. - checker.create_lock() - - assert not checker._get_pid_from_lock() - - -def test_remove_lock(checker: ProcessChecker): - # Test that the lock file can be removed. - checker.create_lock() - assert checker._get_pid_from_lock() - - checker.remove_lock() - assert not checker._get_pid_from_lock() - - -@patch.object(Path, 'unlink', Mock(side_effect=PermissionError)) -def test_remove_lock_with_errors(checker: ProcessChecker): - # Test that the lock file can not be removed in the case of any exception. - checker.create_lock() - checker.remove_lock() - - assert checker._get_pid_from_lock() - - -@patch.object(ProcessChecker, 'check_and_restart_if_necessary', Mock()) -@patch.object(ProcessChecker, 'create_lock', Mock()) -@patch.object(ProcessChecker, 'remove_lock', Mock()) -def test_contextmanager(tmp_path): - # Test that all necessary methods have been called during the context manager using. - with single_tribler_instance(tmp_path) as checker: - assert checker.check_and_restart_if_necessary.called - assert checker.create_lock.called - assert not checker.remove_lock.called - - assert checker.remove_lock.called - - -@patch.object(psutil.Process, 'status', Mock(side_effect=psutil.Error)) -def test_check_psutil_error(checker: ProcessChecker): - # Ensure that the `check` method don`t raise an exception in the case `psutil.Process.status()` - # raises `psutil.Error` exception. - assert not checker.check_and_restart_if_necessary() - - -@pytest.mark.parametrize('cmd_line', TRIBLER_CMD_LINE) -def test_is_tribler_cmd(cmd_line, checker: ProcessChecker): - assert checker._is_tribler_cmd(cmd_line) - - -@pytest.mark.parametrize('cmd_line', NOT_TRIBLER_CMD_LINE) -def test_not_is_tribler_cmd(cmd_line, checker: ProcessChecker): - assert not checker._is_tribler_cmd(cmd_line) - - -@patch.object(ProcessChecker, '_restart_tribler', Mock()) -@patch.object(ProcessChecker, '_close_process', Mock()) -def test_ask_to_restart_yes(checker: ProcessChecker, process: ProcessMock): - # Ensure that when a user choose "Yes" in the message box from the `_ask_to_restart` method, - # `_restart_tribler` is called. - mocked_QApplication = Mock() - mocked_QMessageBox = MagicMock(Yes=QMessageBox.Yes, - return_value=MagicMock(exec_=Mock(return_value=QMessageBox.Yes))) - with patch_import('PyQt5.QtWidgets', strict=True, QApplication=mocked_QApplication, QMessageBox=mocked_QMessageBox): - checker._ask_to_restart(process) - - assert mocked_QMessageBox.called - assert mocked_QApplication.called - assert checker._restart_tribler.called - assert checker._close_process.called - - -@patch.object(ProcessChecker, '_restart_tribler', Mock()) -@patch.object(ProcessChecker, '_close_process', Mock()) -def test_ask_to_restart_no(checker: ProcessChecker, process: ProcessMock): - # Ensure that when a user choose "No" in the message box from the `_ask_to_restart` method, - # `_close_process` is called. - mocked_QApplication = Mock() - mocked_QMessageBox = MagicMock(No=QMessageBox.No, - return_value=MagicMock(exec_=Mock(return_value=QMessageBox.No))) - with patch_import('PyQt5.QtWidgets', strict=True, QApplication=mocked_QApplication, QMessageBox=mocked_QMessageBox): - checker._ask_to_restart(process) - - assert mocked_QMessageBox.called - assert mocked_QApplication.called - assert checker._close_process.called - assert not checker._restart_tribler.called - - -@patch.object(ProcessChecker, '_restart_tribler', Mock()) -@patch.object(ProcessChecker, '_close_process', Mock()) -def test_ask_to_restart_error(checker: ProcessChecker, process: ProcessMock): - # Ensure that in the case of an error in `_ask_to_restart` method, - # `_close_process` is called. - checker._restart_tribler = MagicMock() - checker._close_process = Mock() - with patch_import('PyQt5.QtWidgets', always_raise_exception_on_import=True): - checker._ask_to_restart(process) - - assert not checker._restart_tribler.called - assert checker._close_process.called - - -@patch('os.kill') -@patch('os.getpid', Mock()) -@patch('psutil.pid_exists', Mock(return_value=True)) -def test_close_process(mocked_kill: Mock, checker: ProcessChecker, process: ProcessMock): - checker._close_process(process) - assert mocked_kill.called - - -@patch('os.kill', Mock(side_effect=OSError)) -@patch('os.close', Mock(side_effect=OSError)) -def test_close_process_errors(checker: ProcessChecker, process: ProcessMock): - # Ensure that in the case `os.kill` or `os.close` raises an exception, the `_close_process` - # will never throw it further. - checker._close_process(process) - - -@patch('os.execl') -def test_restart_tribler(mocked_execl: Mock, checker: ProcessChecker): - checker._restart_tribler() - assert mocked_execl.called diff --git a/src/tribler/core/utilities/tests/test_utilities.py b/src/tribler/core/utilities/tests/test_utilities.py index 01ef7fe6c8f..4273445496d 100644 --- a/src/tribler/core/utilities/tests/test_utilities.py +++ b/src/tribler/core/utilities/tests/test_utilities.py @@ -286,13 +286,31 @@ def test_add_url_param_clean(): assert "data=values" in result -def test_load_logger(tmpdir): +@patch('logging.config.dictConfig') +def test_load_logger(dict_config: Mock, tmpdir): """ Test loading the Tribler logger configuration. """ - logger_count = len(logging.root.manager.loggerDict) load_logger_config('test', tmpdir) - assert len(logging.root.manager.loggerDict) >= logger_count + + dict_config.assert_called_once() + config = dict_config.call_args.args[0] + assert config['handlers'].keys() == {'info_file_handler', 'info_memory_handler', + 'error_file_handler', 'error_memory_handler', + 'stdout_handler', 'stderr_handler'} + + +@patch('logging.config.dictConfig') +@patch('tribler.core.logger.logger.logger') +def test_load_logger_no_primary_process(logger: Mock, dict_config: Mock, tmpdir): + """ + Test loading the Tribler logger configuration. + """ + load_logger_config('test', tmpdir, current_process_is_primary=False) + logger.info.assert_called_once() + assert logger.info.call_args.args[0].startswith( + 'Skip the initialization of a normal file-based logging as the current process is non-primary.') + dict_config.assert_not_called() @pytest.mark.skip(reason="Skipping the randomness check as it can sometimes fail.") diff --git a/src/tribler/core/utilities/tiny_tribler_service.py b/src/tribler/core/utilities/tiny_tribler_service.py index 974d948a209..7befb0d3411 100644 --- a/src/tribler/core/utilities/tiny_tribler_service.py +++ b/src/tribler/core/utilities/tiny_tribler_service.py @@ -8,7 +8,8 @@ from tribler.core.components.session import Session from tribler.core.config.tribler_config import TriblerConfig from tribler.core.utilities.osutils import get_root_state_directory -from tribler.core.utilities.process_checker import ProcessChecker +from tribler.core.utilities.process_manager import ProcessKind, ProcessManager, TriblerProcess, \ + set_global_process_manager from tribler.core.utilities.utilities import make_async_loop_fragile @@ -22,7 +23,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_checker: Optional[ProcessChecker] = None + self.process_manager: Optional[ProcessManager] = None self.config = TriblerConfig(state_dir=state_dir.absolute()) self.timeout_in_sec = timeout_in_sec self.components = components @@ -68,9 +69,14 @@ 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() - self.process_checker = ProcessChecker(root_state_dir) - self.process_checker.check_and_restart_if_necessary() - self.process_checker.create_lock() + 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(): + msg = 'Another Core process is already running' + self.logger.warning(msg) + self.process_manager.sys_exit(1, msg) def _enable_graceful_shutdown(self): self.logger.info("Enabling graceful shutdown") @@ -82,18 +88,19 @@ def signal_handler(signum, frame): signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) - def _graceful_shutdown(self): - self.logger.info("Shutdown gracefully") - - if self.process_checker: - self.process_checker.remove_lock() - - task = asyncio.create_task(self.session.shutdown()) - task.add_done_callback(lambda result: asyncio.get_running_loop().stop()) - async def _terminate_by_timeout(self): self.logger.info(f"Scheduling terminating by timeout {self.timeout_in_sec}s from now") await asyncio.sleep(self.timeout_in_sec) self.logger.info("Terminating by timeout") self._graceful_shutdown() + + def _graceful_shutdown(self): + self.logger.info("Shutdown gracefully") + task = asyncio.create_task(self.session.shutdown()) + task.add_done_callback(lambda result: self._stop_event_loop()) + + def _stop_event_loop(self): + asyncio.get_running_loop().stop() + if self.process_manager: + self.process_manager.current_process.finish() diff --git a/src/tribler/gui/core_manager.py b/src/tribler/gui/core_manager.py index f1ba28675ba..37a91b1ee42 100644 --- a/src/tribler/gui/core_manager.py +++ b/src/tribler/gui/core_manager.py @@ -7,9 +7,7 @@ from typing import Optional from PyQt5.QtCore import QObject, QProcess, QProcessEnvironment -from PyQt5.QtNetwork import QNetworkRequest -from tribler.core.utilities.process_checker import ProcessChecker from tribler.gui import gui_sentry_reporter from tribler.gui.app_manager import AppManager from tribler.gui.event_request_manager import EventRequestManager @@ -200,7 +198,7 @@ def send_shutdown_request(initial=False): self._logger.info('Core is not running, quitting GUI application') self.app_manager.quit_application() - def kill_core_process_and_remove_the_lock_file(self): + def kill_core_process(self): if not self.core_process: self._logger.warning("Cannot kill the Core process as it is not initialized") @@ -209,9 +207,6 @@ def kill_core_process_and_remove_the_lock_file(self): if not finished: self._logger.error('Cannot kill the core process') - process_checker = ProcessChecker(self.root_state_dir) - process_checker.remove_lock() - def get_last_core_output(self, quoted=True): output = ''.join(self.last_core_stderr_output) or ''.join(self.last_core_stdout_output) if quoted: diff --git a/src/tribler/gui/dialogs/feedbackdialog.py b/src/tribler/gui/dialogs/feedbackdialog.py index 612fed990e5..88810dd5c6b 100644 --- a/src/tribler/gui/dialogs/feedbackdialog.py +++ b/src/tribler/gui/dialogs/feedbackdialog.py @@ -6,6 +6,7 @@ import sys import time from collections import defaultdict +from typing import TYPE_CHECKING from PyQt5 import uic from PyQt5.QtWidgets import QAction, QDialog, QMessageBox, QTreeWidgetItem @@ -14,18 +15,20 @@ from tribler.core.sentry_reporter.sentry_reporter import SentryReporter from tribler.core.sentry_reporter.sentry_scrubber import SentryScrubber from tribler.core.sentry_reporter.sentry_tools import CONTEXT_DELIMITER, LONG_TEXT_DELIMITER -from tribler.gui.core_manager import CoreManager from tribler.gui.event_request_manager import received_events from tribler.gui.sentry_mixin import AddBreadcrumbOnShowMixin from tribler.gui.tribler_action_menu import TriblerActionMenu from tribler.gui.tribler_request_manager import performed_requests as tribler_performed_requests from tribler.gui.utilities import connect, get_ui_file_path, tr +if TYPE_CHECKING: + from tribler.gui.tribler_window import TriblerWindow + class FeedbackDialog(AddBreadcrumbOnShowMixin, QDialog): def __init__( # pylint: disable=too-many-arguments, too-many-locals self, - parent, + parent: TriblerWindow, sentry_reporter: SentryReporter, reported_error: ReportedError, tribler_version, @@ -34,7 +37,8 @@ def __init__( # pylint: disable=too-many-arguments, too-many-locals additional_tags=None, ): QDialog.__init__(self, parent) - self.core_manager: CoreManager = parent.core_manager + self.core_manager = parent.core_manager + self.process_manager = parent.process_manager uic.loadUi(get_ui_file_path('feedback_dialog.ui'), self) @@ -166,12 +170,15 @@ def on_send_clicked(self, checked): "stack": stack, } + last_processes = [str(p) for p in self.process_manager.get_last_processes()] + self.sentry_reporter.send_event( event=self.reported_error.event, post_data=post_data, sys_info=sys_info_dict, additional_tags=self.additional_tags, - last_core_output=self.reported_error.last_core_output + last_core_output=self.reported_error.last_core_output, + last_processes=last_processes ) self.on_report_sent() diff --git a/src/tribler/gui/error_handler.py b/src/tribler/gui/error_handler.py index 8a4f2889a84..f6271eda5d6 100644 --- a/src/tribler/gui/error_handler.py +++ b/src/tribler/gui/error_handler.py @@ -2,6 +2,7 @@ import logging import traceback +from typing import TYPE_CHECKING from tribler.core.components.reporter.reported_error import ReportedError from tribler.core.sentry_reporter.sentry_reporter import SentryStrategy @@ -11,12 +12,15 @@ from tribler.gui.dialogs.feedbackdialog import FeedbackDialog from tribler.gui.exceptions import CoreError +if TYPE_CHECKING: + from tribler.gui.tribler_window import TriblerWindow + # fmt: off class ErrorHandler: - def __init__(self, tribler_window): + def __init__(self, tribler_window: TriblerWindow): logger_name = self.__class__.__name__ self._logger = logging.getLogger(logger_name) gui_sentry_reporter.ignore_logger(logger_name) @@ -29,6 +33,8 @@ def __init__(self, tribler_window): def gui_error(self, exc_type, exc, tb): self._logger.info(f'Processing GUI error: {exc_type}') + process_manager = self.tribler_window.process_manager + process_manager.current_process.set_error(exc) text = "".join(traceback.format_exception(exc_type, exc, tb)) self._logger.error(text) @@ -79,9 +85,11 @@ def gui_error(self, exc_type, exc, tb): def core_error(self, reported_error: ReportedError): if self._tribler_stopped or reported_error.type in self._handled_exceptions: return - self._logger.info(f'Processing Core error: {reported_error}') self._handled_exceptions.add(reported_error.type) + self._logger.info(f'Processing Core error: {reported_error}') + process_manager = self.tribler_window.process_manager + process_manager.current_process.set_error(f"Core {reported_error.type}: {reported_error.text}") error_text = f'{reported_error.text}\n{reported_error.long_text}' self._logger.error(error_text) diff --git a/src/tribler/gui/single_application.py b/src/tribler/gui/single_application.py index 576e56bddcf..d595b6e35af 100644 --- a/src/tribler/gui/single_application.py +++ b/src/tribler/gui/single_application.py @@ -18,9 +18,9 @@ class QtSingleApplication(QApplication): When a user tries to open a second Tribler instance, the current active one will be brought to front. """ - messageReceived = pyqtSignal(str) + message_received = pyqtSignal(str) - def __init__(self, win_id, *argv): + def __init__(self, win_id: str, start_local_server: bool, *argv): self.logger = logging.getLogger(self.__class__.__name__) self.logger.info(f'Start Tribler application. Win id: "{win_id}". ' f'Sys argv: "{sys.argv}"') @@ -31,73 +31,69 @@ def __init__(self, win_id, *argv): self._id = win_id # Is there another instance running? - self._outSocket = QLocalSocket() - self._outSocket.connectToServer(self._id) - self._isRunning = self._outSocket.waitForConnected() + self._outgoing_connection = QLocalSocket() + self._outgoing_connection.connectToServer(self._id) - self._outStream = None - self._inSocket = None - self._inStream = None + self.connected_to_previous_instance = self._outgoing_connection.waitForConnected() + + self._stream_to_running_app = None + self._incoming_connection = None + self._incoming_stream = None self._server = None - if self._isRunning: - # Yes, there is. + if self.connected_to_previous_instance: self.logger.info('Another instance is running') - self._outStream = QTextStream(self._outSocket) - self._outStream.setCodec('UTF-8') - else: - # No, there isn't, at least not properly. + self._stream_to_running_app = QTextStream(self._outgoing_connection) + self._stream_to_running_app.setCodec('UTF-8') + elif start_local_server: # Cleanup any past, crashed server. - error = self._outSocket.error() + error = self._outgoing_connection.error() self.logger.info(f'No running instances (socket error: {error})') if error == QLocalSocket.ConnectionRefusedError: self.logger.info('Received QLocalSocket.ConnectionRefusedError; removing server.') - self.close() - QLocalServer.removeServer(self._id) - self._outSocket = None + self.cleanup_crashed_server() + self._outgoing_connection = None self._server = QLocalServer() self._server.listen(self._id) connect(self._server.newConnection, self._on_new_connection) - def close(self): - self.logger.info('Closing...') - if self._inSocket: - self._inSocket.disconnectFromServer() - if self._outSocket: - self._outSocket.disconnectFromServer() + def cleanup_crashed_server(self): + self.logger.info('Cleaning up crashed server...') + if self._incoming_connection: + self._incoming_connection.disconnectFromServer() + if self._outgoing_connection: + self._outgoing_connection.disconnectFromServer() if self._server: self._server.close() - self.logger.info('Closed') - - def is_running(self): - return self._isRunning + QLocalServer.removeServer(self._id) + self.logger.info('Crashed server was removed') def get_id(self): return self._id def send_message(self, msg): self.logger.info(f'Send message: {msg}') - if not self._outStream: + if not self._stream_to_running_app: return False - self._outStream << msg << '\n' - self._outStream.flush() - return self._outSocket.waitForBytesWritten() + self._stream_to_running_app << msg << '\n' # pylint: disable=pointless-statement + self._stream_to_running_app.flush() + return self._outgoing_connection.waitForBytesWritten() def _on_new_connection(self): - if self._inSocket: - disconnect(self._inSocket.readyRead, self._on_ready_read) - self._inSocket = self._server.nextPendingConnection() - if not self._inSocket: + if self._incoming_connection: + disconnect(self._incoming_connection.readyRead, self._on_ready_read) + self._incoming_connection = self._server.nextPendingConnection() + if not self._incoming_connection: return - self._inStream = QTextStream(self._inSocket) - self._inStream.setCodec('UTF-8') - connect(self._inSocket.readyRead, self._on_ready_read) + self._incoming_stream = QTextStream(self._incoming_connection) + self._incoming_stream.setCodec('UTF-8') + connect(self._incoming_connection.readyRead, self._on_ready_read) if self.tribler_window: self.tribler_window.restore_from_minimised() def _on_ready_read(self): while True: - msg = self._inStream.readLine() + msg = self._incoming_stream.readLine() if not msg: break - self.messageReceived.emit(msg) + self.message_received.emit(msg) diff --git a/src/tribler/gui/start_gui.py b/src/tribler/gui/start_gui.py index d58c862e055..b06307a01ef 100644 --- a/src/tribler/gui/start_gui.py +++ b/src/tribler/gui/start_gui.py @@ -8,13 +8,15 @@ check_and_enable_code_tracing, check_environment, check_free_space, - enable_fault_handler, - error_and_exit, + enable_fault_handler ) from tribler.core.exceptions import TriblerException 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.utilities import show_system_popup from tribler.gui import gui_sentry_reporter from tribler.gui.app_manager import AppManager from tribler.gui.tribler_app import TriblerApplication @@ -25,7 +27,7 @@ def run_gui(api_port, api_key, root_state_dir, parsed_args): - logger.info('Running GUI' + ' in gui_test_mode' if parsed_args.gui_test_mode else '') + 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 if sys.platform == "darwin": @@ -36,19 +38,24 @@ def run_gui(api_port, api_key, root_state_dir, parsed_args): logger.info('Enabling a workaround for Ubuntu 21.04+ wayland environment') os.environ["GDK_BACKEND"] = "x11" - # Set up logging - load_logger_config('tribler-gui', root_state_dir) + 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() + + load_logger_config('tribler-gui', root_state_dir, current_process_is_primary) # Enable tracer using commandline args: --trace-debug or --trace-exceptions trace_logger = check_and_enable_code_tracing('gui', root_state_dir) - try: - enable_fault_handler(root_state_dir) - # Exit if we cant read/write files, etc. - check_environment() - check_free_space() + enable_fault_handler(root_state_dir) + # Exit if we cant read/write files, etc. + check_environment() + check_free_space() + + try: app_name = os.environ.get('TRIBLER_APP_NAME', 'triblerapp') - app = TriblerApplication(app_name, sys.argv) + app = TriblerApplication(app_name, sys.argv, start_local_server=current_process_is_primary) app_manager = AppManager(app) # Note (@ichorid): translator MUST BE created and assigned to a separate variable @@ -57,7 +64,7 @@ def run_gui(api_port, api_key, root_state_dir, parsed_args): translator = get_translator(settings.value('translation', None)) app.installTranslator(translator) - if app.is_running(): + if not current_process_is_primary and app.connected_to_previous_instance: # if an application is already running, then send the command line # argument to it and close the current instance logger.info('GUI Application is already running. Passing a torrent file path to it.') @@ -67,22 +74,21 @@ def run_gui(api_port, api_key, root_state_dir, parsed_args): elif arg.startswith('magnet'): app.send_message(arg) logger.info('Close the current application.') - sys.exit(1) + process_manager.sys_exit(1, 'Tribler GUI application is already running') logger.info('Start Tribler Window') - window = TriblerWindow(app_manager, settings, root_state_dir, api_port=api_port, api_key=api_key) + window = TriblerWindow(process_manager, app_manager, settings, root_state_dir, + api_port=api_port, api_key=api_key) window.setWindowTitle("Tribler") app.tribler_window = window app.parse_sys_args(sys.argv) - sys.exit(app.exec_()) - - except ImportError as ie: - logger.exception(ie) - error_and_exit("Import Error", f"Import error: {ie}") + exit_code = app.exec_() + process_manager.sys_exit(exit_code or None) - except TriblerException as te: - logger.exception(te) - error_and_exit("Tribler Exception", f"{te}") + except Exception as exc: # pylint: disable=broad-except + logger.exception(exc) + show_system_popup("Tribler Exception", f"{exc.__class__.__name__}: {exc}") + process_manager.sys_exit(1, exc) except SystemExit: logger.info("Shutting down Tribler") diff --git a/src/tribler/gui/tests/test_gui.py b/src/tribler/gui/tests/test_gui.py index 622c622a64d..bb4b7f803c3 100644 --- a/src/tribler/gui/tests/test_gui.py +++ b/src/tribler/gui/tests/test_gui.py @@ -15,6 +15,7 @@ from tribler.core.components.reporter.reported_error import ReportedError from tribler.core.sentry_reporter.sentry_reporter import SentryReporter from tribler.core.tests.tools.common import TESTS_DATA_DIR +from tribler.core.utilities.process_manager import ProcessKind, ProcessManager, TriblerProcess from tribler.core.utilities.rest_utils import path_to_url from tribler.core.utilities.unicode import hexlify from tribler.gui.app_manager import AppManager @@ -36,17 +37,21 @@ # pylint: disable=protected-access @pytest.fixture(name='window', scope="module") -def fixture_window(tmpdir_factory): +def fixture_window(tmp_path_factory): api_key = hexlify(os.urandom(16)) - root_state_dir = str(tmpdir_factory.mktemp('tribler_state_dir')) + root_state_dir = tmp_path_factory.mktemp('tribler_state_dir') - app = TriblerApplication("triblerapp-guitest", sys.argv) + 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) 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. settings = QSettings("tribler-guitest") settings.clear() window = TriblerWindow( + process_manager, app_manager, settings, root_state_dir, diff --git a/src/tribler/gui/tribler_app.py b/src/tribler/gui/tribler_app.py index 97a5ddadf19..a9926c94f6a 100644 --- a/src/tribler/gui/tribler_app.py +++ b/src/tribler/gui/tribler_app.py @@ -23,10 +23,10 @@ class TriblerApplication(QtSingleApplication): This class represents the main Tribler application. """ - def __init__(self, app_name, args): - QtSingleApplication.__init__(self, app_name, args) + def __init__(self, app_name: str, args: list, start_local_server: bool = False): + QtSingleApplication.__init__(self, app_name, start_local_server, args) self.code_executor = None - connect(self.messageReceived, self.on_app_message) + connect(self.message_received, self.on_app_message) def on_app_message(self, msg): if msg.startswith('file') or msg.startswith('magnet'): diff --git a/src/tribler/gui/tribler_window.py b/src/tribler/gui/tribler_window.py index 014227b2aee..19a978bc01b 100644 --- a/src/tribler/gui/tribler_window.py +++ b/src/tribler/gui/tribler_window.py @@ -44,6 +44,7 @@ from psutil import LINUX from tribler.core.upgrade.version_manager import VersionHistory +from tribler.core.utilities.process_manager import ProcessManager from tribler.core.utilities.network_utils import default_network_utils from tribler.core.utilities.rest_utils import ( FILE_SCHEME, @@ -153,9 +154,10 @@ class TriblerWindow(QMainWindow): def __init__( self, + process_manager: ProcessManager, app_manager: AppManager, settings, - root_state_dir, + root_state_dir: Path, core_args=None, core_env=None, api_port=None, @@ -164,6 +166,7 @@ def __init__( ): QMainWindow.__init__(self) self._logger = logging.getLogger(self.__class__.__name__) + self.process_manager = process_manager self.app_manager = app_manager QCoreApplication.setOrganizationDomain("nl") @@ -172,7 +175,7 @@ def __init__( self.setWindowIcon(QIcon(QPixmap(get_image_path('tribler.png')))) - self.root_state_dir = Path(root_state_dir) + self.root_state_dir = root_state_dir self.gui_settings = settings api_port = api_port or default_network_utils.get_first_free_port( start=int(get_gui_setting(self.gui_settings, "api_port", DEFAULT_API_PORT)) @@ -182,6 +185,7 @@ def __init__( "Tribler configuration conflicts with the current OS state: " "REST API port %i already in use" % api_port ) + process_manager.current_process.set_api_port(api_port) api_key = format_api_key(api_key or get_gui_setting(self.gui_settings, "api_key", None) or create_api_key()) set_api_key(self.gui_settings, api_key)