diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ded1e2..6d2ae86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.3.0] 2022-09-15 +### Improvements +- Properly detecting all Steam games subprocesses that need to be optimized (no need to map launchers anymore) +- Launcher mapping now optimizes all matches instead of just the last born process +- New optimizer service property `launcher.mapping.found_timeout`: maximum time in *seconds* to still keep looking for a process mapped to a different process after a match. This property also affects the period to look for Steam subprocesses. (default: `10`) +- The optimizer service property `launcher.mapping.timeout` has now a default value of `60` (seconds) + +### Fix +- wild card mapping to proper regex pattern + ## [1.2.2] 2022-09-22 ### Improvements - Minor code refactoring and log improvements regarding AMD GPU management diff --git a/README.md b/README.md index c2b14f6..3246eda 100644 --- a/README.md +++ b/README.md @@ -460,24 +460,25 @@ makepkg -si - It applies the optimizations changes only once per optimization request. So, if it changes the CPUs governors to performance, and you manually change them later to something else, it will not set the governors to performance again (unless another optimization request arrives). - [Service definition file](https://github.com/vinifmor/guapow/blob/master/guapow/dist/daemon/systemd/root/guapow-opt.service) - By default, it runs at port **5087** and demands encrypted requests for security reasons (all done automatically). -- Its settings are defined on the files: `~/.config/guapow/opt.conf` (user) or `/etc/guapow/opt.conf` (system). Preference: user > system (if running as **root**, system is the only option) - - Properties: + - Its settings are defined on the files: `~/.config/guapow/opt.conf` (user) or `/etc/guapow/opt.conf` (system). Preference: user > system (if running as **root**, system is the only option) + - Properties: + ``` + port = 5087 (TCP port) + compositor = (pre-defines the installed compositor. Options: kwin, compiz, marco, picom, compton, nvidia) + scripts.allow_root = false (allow custom scripts/commands to run at the root level) + check.finished.interval = 3 (finished applications checking interval in seconds. Min accepted value: 0.5) + launcher.mapping.timeout = 60 (maximum time in seconds to look for a process mapped to a different process. This property also affects the period to look for Steam subprocesses. float values are allowed) + launcher.mapping.found_timeout = 10 (maximum time in seconds to still keep looking for a process mapped to a different process after a match. This property also affects the period to look for Steam subprocesses. float values are allowed) + gpu.cache = false (if 'true': maps all available GPUs once after the first request (if running as a system service) or during startup (if not running as system service). Otherwise, GPUs will be mapped for every request) + gpu.id = # comma separated list of integers representing which GPU cards should be optimized (e.g: 0, 1). If not defined, all available GPUs are considered (default) + gpu.vendor = # pre-defines your GPU vendor for faster GPUs mapping. Supported: nvidia, amd + cpu.performance = false (set cpu governors and energy policy levels to full performance on startup) + request.allowed_users = (restricts users that can request optimizations, separated by comma. e.g: root,xpto) + request.encrypted = true (only accepts encrypted requests for security reasons) + profile.cache = false (cache profile files on demand to skip I/O operations. Changes to profile files require restarting) + profile.pre_caching = false (loads all existing profile files on disk in memory during the intialization process. Requires 'profile.cache' enabled) + nice.check.interval = 5 (processes nice levels monitoring interval in seconds) ``` - port = 5087 (TCP port) - compositor = (pre-defines the installed compositor. Options: kwin, compiz, marco, picom, compton, nvidia) - scripts.allow_root = false (allow custom scripts/commands to run at the root level) - check.finished.interval = 3 (finished applications checking interval in seconds. Min accepted value: 0.5) - launcher.mapping.timeout = 30 (max time in seconds to find the application mapped to a given launcher. float values are allowed) - gpu.cache = false (if 'true': maps all available GPUs once after the first request (if running as a system service) or during startup (if not running as system service). Otherwise, GPUs will be mapped for every request) - gpu.id = # comma separated list of integers representing which GPU cards should be optimized (e.g: 0, 1). If not defined, all available GPUs are considered (default) - gpu.vendor = # pre-defines your GPU vendor for faster GPUs mapping. Supported: nvidia, amd - cpu.performance = false (set cpu governors and energy policy levels to full performance on startup) - request.allowed_users = (restricts users that can request optimizations, separated by comma. e.g: root,xpto) - request.encrypted = true (only accepts encrypted requests for security reasons) - profile.cache = false (cache profile files on demand to skip I/O operations. Changes to profile files require restarting) - profile.pre_caching = false (loads all existing profile files on disk in memory during the intialization process. Requires 'profile.cache' enabled) - nice.check.interval = 5 (processes nice levels monitoring interval in seconds) - ``` - Its installation can be managed using the **guapow-cli** tool: - `guapow-cli install-optimizer`: copies the service definition file to the appropriate directory, starts and enables it. diff --git a/example/launcher/games/launchers b/example/launcher/games/launchers index 280e679..026afb8 100644 --- a/example/launcher/games/launchers +++ b/example/launcher/games/launchers @@ -1,30 +1,9 @@ BootGGXrd.bat=GuiltyGearXrd.e # Guilty Gear Xrd TEKKEN 7.exe=TekkenGame-Win6 # Tekken 7 +Out of Space.x86_64=Out of Space.x8 # Out Of Space (windows) MVCI.exe=c%*\Win64\MVCI.exe # Marvel vs Capcom Infinite trine2_launcher.exe=c%trine2_32bit.exe # Trine 2 (windows) -DBFighterZ.exe=RED-Win64-Shipp # Dragon Ball FighterZ -launcher.exe=Fouc.exe # FlatOut: Ultimate Carnage (windows) -Pacer.exe=Pacer-Win64-Shi # Pacer -XenonRacer.exe=XenonRacer-Win6 # Xenon Racer -hl2.sh*=hl2_linux # Portal 1 / Team Fortress 2 (native) Overlanders.exe -FULLSCREEN=Overlanders-Win # Overlanders -ATV.exe=Racer-Win64-Shi # ATV Drift and Tricks -Out of Space.x86_64=Out of Space.x8 # Out Of Space (windows) -DH.exe=DH-Win64-Shippi # Destroy All Humans -Launcher.exe*-uplay_steam_mode=PrinceOfPersia_ # Prince Of Persia (2008) -BmLauncher.exe=ShippingPC-BmGa # Batman Arkham Asylum: GOTY -Dirt4.sh=Dirt4 # Dirt 4 (native) -EverspaceWithSystemLibraries.sh=RSG-Linux-Shipp # Everspace (native) -RSG-Win64-Shipping.exe*=RSG-Win64-Shipp # Everspace (windows) -Sonic & SEGA All-Stars Racing.exe=Sonic & SEGA Al # Sonic and Sega All-Stars Racing Transformed +Launcher.exe*-uplay_steam_mode=PrinceOfPersia_ # Prince Of Persia (2008) portal2.sh*=portal2_linux # Portal 2 (native) -StreetFighterV.exe=StreetFighterV. # Street Fighter V -HellbladeGame.exe=HellbladeGame-W # Hellblade: Senua's Sacrifice -SamuraiShodown.exe=SamuraiShodown- # Samurai Shodown (2020) -csgo.sh*=csgo_linux64 # CS Go (native) -MeowMotors.exe=MeowMotors-Win6 # Meow Motors ShadowOfTheTombRaider.sh=c%*/ShadowOfTheTombRaider # Shadow Of The Tomb Raider (native) -Raji.exe=Raji-Win64-Ship # Raji: an ancient epic -start_protected_game.exe*=MultiVersus-Win # MultiVersus -Mordhau.exe=CrRendererMain # Mordhau -COTS.exe=BlueCode-Win64- # Call Of The Sea diff --git a/guapow/__init__.py b/guapow/__init__.py index e631cb0..5c2e02e 100644 --- a/guapow/__init__.py +++ b/guapow/__init__.py @@ -2,4 +2,4 @@ ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) __app_name__ = 'guapow' -__version__ = '1.2.2' +__version__ = '1.3.0' diff --git a/guapow/common/config.py b/guapow/common/config.py index 7b9ecc8..277c42d 100644 --- a/guapow/common/config.py +++ b/guapow/common/config.py @@ -75,6 +75,7 @@ class OptimizerConfig(RootFileModel): 'scripts.allow_root': ('allow_root_scripts', bool, True), 'check.finished.interval': ('check_finished_interval', float, None), 'launcher.mapping.timeout': ('launcher_mapping_timeout', float, None), + 'launcher.mapping.found_timeout': ('launcher_mapping_found_timeout', float, None), 'gpu.cache': ('gpu_cache', bool, True), 'gpu.id': ('gpu_ids', Set[int], None), 'gpu.vendor': ('gpu_vendor', str, None), @@ -85,16 +86,17 @@ class OptimizerConfig(RootFileModel): def __init__(self, port: Optional[int] = None, compositor: Optional[str] = None, allow_root_scripts: Optional[bool] = False, - check_finished_interval: Optional[float] = None, launcher_mapping_timeout: Optional[float] = 30, - gpu_cache: Optional[bool] = False, cpu_performance: Optional[bool] = None, - profile_cache: Optional[bool] = None, pre_cache_profiles: Optional[bool] = None, - gpu_vendor: Optional[str] = None, renicer_interval: Optional[float] = None, - gpu_ids: Optional[Set[int]] = None): + check_finished_interval: Optional[float] = None, launcher_mapping_timeout: Optional[float] = 60, + launcher_mapping_found_timeout: Optional[float] = 10, gpu_cache: Optional[bool] = False, + cpu_performance: Optional[bool] = None, profile_cache: Optional[bool] = None, + pre_cache_profiles: Optional[bool] = None, gpu_vendor: Optional[str] = None, + renicer_interval: Optional[float] = None, gpu_ids: Optional[Set[int]] = None): self.port = port self.compositor = compositor self.allow_root_scripts = allow_root_scripts self.check_finished_interval = check_finished_interval self.launcher_mapping_timeout = launcher_mapping_timeout + self.launcher_mapping_found_timeout = launcher_mapping_found_timeout self.gpu_cache = gpu_cache self.gpu_vendor = gpu_vendor self.gpu_ids = gpu_ids @@ -121,6 +123,7 @@ def is_valid(self) -> bool: self.profile_cache is not None, self.has_valid_check_finished_interval(), self.has_valid_launcher_mapping_timeout(), + self.has_valid_launcher_mapping_found_timeout(), self.has_valid_renicer_interval()]) def has_valid_port(self) -> bool: @@ -129,6 +132,9 @@ def has_valid_port(self) -> bool: def has_valid_launcher_mapping_timeout(self) -> bool: return self.launcher_mapping_timeout is not None and self.launcher_mapping_timeout >= 0 + def has_valid_launcher_mapping_found_timeout(self) -> bool: + return self.launcher_mapping_found_timeout is not None and self.launcher_mapping_found_timeout >= 0 + def has_valid_check_finished_interval(self) -> bool: return self.check_finished_interval is not None and self.check_finished_interval >= 0.5 @@ -149,7 +155,10 @@ def setup_valid_properties(self): self.check_finished_interval = 3 if not self.has_valid_launcher_mapping_timeout(): - self.launcher_mapping_timeout = 30 + self.launcher_mapping_timeout = 60 + + if not self.has_valid_launcher_mapping_found_timeout(): + self.launcher_mapping_found_timeout = 10 if self.gpu_cache is None: self.gpu_cache = False @@ -212,7 +221,7 @@ def is_service() -> bool: @classmethod def empty(cls) -> "OptimizerConfig": instance = cls(allow_root_scripts=None, check_finished_interval=None, launcher_mapping_timeout=None, - gpu_cache=None) + gpu_cache=None, launcher_mapping_found_timeout=None) instance.request = None return instance diff --git a/guapow/common/steam.py b/guapow/common/steam.py index 7352abc..83ca2d4 100644 --- a/guapow/common/steam.py +++ b/guapow/common/steam.py @@ -1,26 +1,3 @@ -import os.path import re -from typing import Optional, Tuple RE_STEAM_CMD = re.compile(r'^.+\s+SteamLaunch\s+AppId\s*=\s*\d+\s+--\s+(.+)') -RE_PROTON_CMD = re.compile(r'^.+/proton\s+waitforexitandrun\s+(/.+)$') -RE_EXE_NAME = re.compile(r'^(.+\.\w+)(\s+.+)?$') - - -def get_exe_name(file_path: str) -> Optional[str]: - exe_name = RE_EXE_NAME.findall(os.path.basename(file_path)) - return exe_name[0][0].strip() if exe_name else None - - -def get_proton_exec_name_and_paths(cmd: str) -> Optional[Tuple[Optional[str], str, str]]: - if cmd: - result = RE_PROTON_CMD.findall(cmd) - if result: - return get_exe_name(result[0]), 'Z:{}'.format(result[0].replace('/', '\\')), result[0] - - -def get_steam_runtime_command(cmd: str) -> Optional[str]: - if cmd: - result = RE_STEAM_CMD.findall(cmd) - if result: - return result[0].strip() diff --git a/guapow/common/util.py b/guapow/common/util.py index b4fcd1b..3fd1608 100644 --- a/guapow/common/util.py +++ b/guapow/common/util.py @@ -19,7 +19,7 @@ def has_any_regex(word: str) -> bool: def map_only_any_regex(word: str) -> Optional[Pattern]: escaped_word = re.escape(re_any_operator.sub('@', word)) - return re.compile(r'^{}$'.format(escaped_word.replace('@', '.+'))) + return re.compile(r'^{}$'.format(escaped_word.replace('@', '.*'))) def strip_file_extension(cmd: str) -> Optional[str]: diff --git a/guapow/dist/daemon/opt.conf b/guapow/dist/daemon/opt.conf index 7eca072..0edb9cb 100644 --- a/guapow/dist/daemon/opt.conf +++ b/guapow/dist/daemon/opt.conf @@ -5,7 +5,8 @@ # compositor = # pre-defines the installed compositor. Options: kwin, compiz, marco, picom, compton, nvidia # scripts.allow_root = false # allow custom scripts/commands to run at the root level # check.finished.interval = 3 # finished applications checking interval in seconds -# launcher.mapping.timeout = 30 # max time in seconds to find the application mapped to a given launcher. float values are allowed +# launcher.mapping.timeout = 60 (maximum time in seconds to look for a process mapped to a different process. This property also affects the period to look for Steam subprocesses. float values are allowed) +# launcher.mapping.found_timeout = 10 (maximum time in seconds to still keep looking for a process mapped to a different process after a match. This property also affects the period to look for Steam subprocesses. float values are allowed) # gpu.cache = false # if 'true': maps all available GPUs on startup. Otherwise, GPUs will be mapped for every request # gpu.id = # comma separated list of integers representing which GPU cards should be optimized (e.g: 0, 1). If not defined, all available GPUs are considered (default) # gpu.vendor = # pre-defines your GPU vendor for faster GPUs mapping. Supported: nvidia, amd diff --git a/guapow/service/optimizer/flow.py b/guapow/service/optimizer/flow.py index 19653d4..fcbc159 100644 --- a/guapow/service/optimizer/flow.py +++ b/guapow/service/optimizer/flow.py @@ -1,4 +1,5 @@ from asyncio import Lock +from logging import Logger from typing import Set, Optional @@ -7,15 +8,22 @@ class OptimizationQueue: Responsible to properly control what is being processed """ - def __init__(self, queued_pids: Set[int]): + def __init__(self, queued_pids: Set[int], logger: Optional[Logger] = None): self._queued_pids = queued_pids self._lock_queued_pids = Lock() + self._logger = logger + + def _log(self, msg: str): + if self._logger: + self._logger.debug(msg) async def add_pid(self, pid: int) -> bool: - async with self._lock_queued_pids: - if pid not in self._queued_pids: - self._queued_pids.add(pid) - return True + if pid is not None: + async with self._lock_queued_pids: + if pid not in self._queued_pids: + self._log(f"Adding pid {pid} to the optimization queue") + self._queued_pids.add(pid) + return True return False @@ -23,7 +31,9 @@ async def remove_pids(self, *pids: int): if pids is not None: async with self._lock_queued_pids: for pid in pids: - self._queued_pids.discard(pid) + if pid is not None: + self._log(f"Removing pid {pid} from the optimization queue") + self._queued_pids.discard(pid) def get_view(self) -> Optional[Set[int]]: if self._queued_pids is not None: diff --git a/guapow/service/optimizer/handler.py b/guapow/service/optimizer/handler.py index a5b8adc..016b20d 100644 --- a/guapow/service/optimizer/handler.py +++ b/guapow/service/optimizer/handler.py @@ -1,13 +1,15 @@ import asyncio import os import time -from typing import Optional, Awaitable, List +from asyncio import Task +from typing import Optional, Awaitable, Tuple, Iterable, AsyncGenerator, List from guapow.common.dto import OptimizationRequest from guapow.common.profile import get_possible_profile_paths_by_priority, \ get_default_profile_name from guapow.service.optimizer.launcher import LauncherMapperManager from guapow.service.optimizer.profile import OptimizationProfile, OptimizationProfileReader +from guapow.service.optimizer.task.environment import EnvironmentTask from guapow.service.optimizer.task.manager import TasksManager, run_tasks from guapow.service.optimizer.task.model import OptimizationContext, OptimizedProcess from guapow.service.optimizer.watch import DeadProcessWatcherManager @@ -24,13 +26,16 @@ def __init__(self, context: OptimizationContext, tasks_man: TasksManager, watche self._tasks_man = tasks_man self._watcher_man = watcher_man self._profile_reader = profile_reader - self._launcher_mapper = LauncherMapperManager(check_time=context.launcher_mapping_timeout, logger=context.logger) + self._launcher_mapper = LauncherMapperManager(check_time=context.launcher_mapping_timeout, + found_check_time=context.launcher_mapping_found_timeout, + logger=context.logger) async def _read_valid_profile(self, name: str, add_settings: Optional[str], user_id: Optional[int], user_name: Optional[str], request: OptimizationRequest) -> Optional[OptimizationProfile]: for file_path in get_possible_profile_paths_by_priority(name=name, user_id=user_id, user_name=user_name): if file_path: try: - return await self._profile_reader.read_valid(profile_path=file_path, add_settings=add_settings, handle_not_found=False) + return await self._profile_reader.read_valid(profile_path=file_path, add_settings=add_settings, + handle_not_found=False) except FileNotFoundError: self._log.debug(f"Profile file '{file_path}' not found (request={request.pid})") @@ -61,24 +66,59 @@ def map_valid_config(self, config: str) -> Optional[OptimizationProfile]: self._log.warning("No optimization settings defined in configuration: {}".format(config.replace('\n', ' '))) - async def _start_environment_tasks(self, process: OptimizedProcess) -> Optional[List[Awaitable]]: - env_tasks = await self._tasks_man.get_available_environment_tasks(process) + async def _generate_process_tasks(self, source_process: OptimizedProcess) -> \ + AsyncGenerator[Tuple[OptimizedProcess, Iterable[Awaitable]], None]: + """ + Generates tasks related to the source process or mapped processes. + """ + if source_process.profile.process: + proc_tasks = await self._tasks_man.get_available_process_tasks(source_process) + if proc_tasks: + any_mapped = False + async for pid in self._launcher_mapper.map_pids(source_process.request, source_process.profile): + if pid is not None and pid != source_process.pid: + await self._queue.add_pid(pid) + any_mapped = True + cloned_process = source_process.clone() + cloned_process.pid = pid + yield cloned_process, run_tasks(proc_tasks, cloned_process) + + if not any_mapped: + yield source_process, run_tasks(proc_tasks, source_process) + + async def _handle_process(self, process: OptimizedProcess, tasks: Optional[Iterable[Awaitable]] = None, + env_tasks: Optional[Iterable[Task]] = None): if env_tasks: - return run_tasks(env_tasks, process) + self._log.debug(f"Awaiting environment tasks required by process '{process.pid}'") + await asyncio.gather(*run_tasks(env_tasks, process)) - async def _start_process_tasks(self, process: OptimizedProcess) -> Optional[List[Awaitable]]: - if process.profile.process: - proc_tasks = await self._tasks_man.get_available_process_tasks(process) + should_be_watched = process.should_be_watched() - if proc_tasks: - mapped_pid = await self._launcher_mapper.map_pid(process.request, process.profile) + if should_be_watched: + self._log.debug(f"Process '{process.pid}' should be watched") + await self._watcher_man.watch(process) + + if process.pid != process.source_pid: + await self._queue.remove_pids(process.source_pid) - if mapped_pid is not None: - process.pid = mapped_pid - await self._queue.add_pid(mapped_pid) + if tasks: + self._log.debug(f"Awaiting process tasks required by the process '{process.pid}'") + await asyncio.gather(*tasks) - return run_tasks(proc_tasks, process) + if not should_be_watched: + self._log.debug(f"Process '{process.pid}' does not require watching") + related_pids = process.get_pids() + + if related_pids: + self._log.debug(f"Disassociating process '{process.pid}' related pids " + f"({', '.join(str(p) for p in related_pids)}) from the optimization queue") + await self._queue.remove_pids(*related_pids) + + request = process.request + exec_time = time.time() - request.created_at + self._log.debug(f"Optimization request for process '{process.pid}' took {exec_time:.4f} seconds" + f"{f' (source pid={process.pid})' if request.pid != process.pid else ''}") async def handle(self, request: OptimizationRequest): request.prepare() @@ -95,34 +135,19 @@ async def handle(self, request: OptimizationRequest): if not profile: self._log.warning(f"No optimizations available for process '{request.pid}'") - process = OptimizedProcess(request=request, created_at=time.time(), profile=profile) - - proc_tasks_running = None + source_process = OptimizedProcess(request=request, created_at=time.time(), profile=profile) + env_tasks: Optional[List[EnvironmentTask]] = None if profile: - env_tasks_running = await self._start_environment_tasks(process) - proc_tasks_running = await self._start_process_tasks(process) - - if env_tasks_running: - await asyncio.gather(*env_tasks_running) - - should_be_watched = process.should_be_watched() - - if should_be_watched: - await self._watcher_man.watch(process) - - if process.pid != process.source_pid: - await self._queue.remove_pids(process.source_pid) - - if proc_tasks_running: - await asyncio.gather(*proc_tasks_running) + env_tasks = await self._tasks_man.get_available_environment_tasks(source_process) - if not should_be_watched: - related_pids = process.get_pids() + pids_handled = False + async for mapped_proc, proc_tasks in self._generate_process_tasks(source_process): + await self._handle_process(mapped_proc, proc_tasks, env_tasks) + pids_handled = True - if related_pids: - await self._queue.remove_pids(*related_pids) + if pids_handled: + return - exec_time = time.time() - request.created_at - self._log.debug(f"Optimization request for '{request.pid}' took {exec_time:.4f} seconds" - f"{f' (target_pid={process.pid})' if request.pid != process.pid else ''}") + # only tries to handle the source process in case it wasn't mapped as other processes + await self._handle_process(source_process, env_tasks=env_tasks) diff --git a/guapow/service/optimizer/launcher.py b/guapow/service/optimizer/launcher.py index d59fa6e..6cd1cfe 100644 --- a/guapow/service/optimizer/launcher.py +++ b/guapow/service/optimizer/launcher.py @@ -1,18 +1,17 @@ import asyncio import re -import time from abc import ABC, abstractmethod from datetime import datetime, timedelta from logging import Logger -from typing import Dict, Optional, Tuple, List, Generator +from typing import Dict, Optional, Tuple, Generator, AsyncGenerator, Pattern, Set, List import aiofiles from guapow import __app_name__ -from guapow.common import util, system, steam +from guapow.common import util from guapow.common.dto import OptimizationRequest from guapow.common.model import CustomEnum -from guapow.common.steam import get_exe_name +from guapow.common.system import async_syscall from guapow.common.users import is_root_user from guapow.service.optimizer.profile import OptimizationProfile @@ -93,13 +92,25 @@ async def map_launchers_file(wrapper_file: str, logger: Logger) -> Optional[Dict class LauncherMapper(ABC): + """ + Responsible for mapping the real processes to be optimized since a source process. + """ - def __init__(self, check_time: float, logger: Logger): + def __init__(self, check_time: float, found_check_time: float, logger: Logger): + """ + Args: + check_time: the maximum amount of time the mapper should be looking for a match (seconds) + found_check_time: + the maximum amount of time the mapper should be still looking for matches after some already found + (seconds) + logger: + """ self._log = logger self._check_time = check_time + self._found_check_time = found_check_time @abstractmethod - async def map_pid(self, request: OptimizationRequest, profile: OptimizationProfile) -> Optional[int]: + async def map_pids(self, request: OptimizationRequest, profile: OptimizationProfile) -> AsyncGenerator[int, None]: pass @@ -108,36 +119,79 @@ class ExplicitLauncherMapper(LauncherMapper): For mappings declared in the 'launchers' file """ - def __init__(self, wait_time: float, logger: Logger): - super(ExplicitLauncherMapper, self).__init__(check_time=wait_time, logger=logger) + def __init__(self, check_time: float, found_check_time: float, logger: Logger, + iteration_sleep_time: float = 0.1): + super(ExplicitLauncherMapper, self).__init__(check_time=check_time, + found_check_time=found_check_time, + logger=logger) + self._iteration_sleep_time = iteration_sleep_time + + @staticmethod + async def map_process_by_pid(mode: LauncherSearchMode, ignore: Set[int]) -> Optional[Dict[int, str]]: + if mode: + mode_str = "a" if mode == LauncherSearchMode.COMMAND else "c" + exitcode, output = await async_syscall(f'ps -Ao "%p#%{mode_str}" -ww --no-headers') + + if exitcode == 0 and output: + pid_comm = dict() + + for line in output.split("\n"): + line_strip = line.strip() + + if line_strip: + line_split = line_strip.split('#', 1) + + if len(line_split) > 1: + try: + pid = int(line_split[0]) + except ValueError: + continue + + if pid in ignore: + continue - async def _find_wrapped_process(self, wrapped_target: Tuple[str, LauncherSearchMode], launcher: str) -> Optional[int]: - wrapped_name, search_mode = wrapped_target[0], wrapped_target[1] + pid_comm[pid] = line_split[1].strip() + + return pid_comm + + async def find_wrapped_process(self, wrapped_target: Tuple[str, LauncherSearchMode], launcher: str, + source_pid: int) -> AsyncGenerator[int, None]: + wrapped_name, search_mode = wrapped_target[0].strip(), wrapped_target[1] wrapped_regex = util.map_any_regex(wrapped_name) - wrapped_regexes = {wrapped_regex} - self._log.debug(f"Looking for mapped process with {search_mode.name.lower()} '{wrapped_name}' (launcher={launcher})") + self._log.debug(f"Looking for mapped process with {search_mode.name.lower()} '{wrapped_name}' " + f"(launcher={launcher})") + latest_found_timeout = None + found = set() time_init = datetime.now() - time_limit = time_init + timedelta(seconds=self._check_time) + timeout = time_init + timedelta(seconds=self._check_time) + while datetime.now() < timeout: + if latest_found_timeout and datetime.now() >= latest_found_timeout: + self._log.debug(f"Launcher mapping search timed out earlier (source_pid={source_pid})") + return - while datetime.now() < time_limit: - if search_mode == LauncherSearchMode.COMMAND: - wrapped_proc = await system.find_process_by_command(wrapped_regexes, last_match=True) - else: - wrapped_proc = await system.find_process_by_name(wrapped_regex, last_match=True) + pid_process = await self.map_process_by_pid(search_mode, ignore=found) - if wrapped_proc is not None: - find_time = (datetime.now() - time_init).total_seconds() - pid_found, name_found = wrapped_proc[0], wrapped_proc[1] - self._log.info(f"Mapped process '{name_found}' ({pid_found}) found in {find_time:.2f} seconds") - return pid_found - else: - await asyncio.sleep(0.001) + if pid_process: + for pid, command in pid_process.items(): + if wrapped_regex.match(command): + if self._found_check_time >= 0: + latest_found_timeout = datetime.now() + timedelta(seconds=self._found_check_time) + + self._log.info(f"Mapped process '{command}' ({pid}) found") + yield pid + found.add(pid) - find_time = (datetime.now() - time_init).total_seconds() - self._log.warning(f"Could not find process with {search_mode.name.lower()} '{wrapped_name}' (launcher={launcher}). Timed out in {find_time:.2f} seconds") + if self._iteration_sleep_time > 0: + await asyncio.sleep(self._iteration_sleep_time) - async def map_pid(self, request: OptimizationRequest, profile: OptimizationProfile) -> Optional[int]: + if not found: + timeout_secs = (datetime.now() - time_init).total_seconds() + self._log.warning(f"Could not find process with {search_mode.name.lower()} '{wrapped_name}' " + f"(launcher={launcher}, source_pid={source_pid}). " + f"Timed out in {timeout_secs:.2f} seconds") + + async def map_pids(self, request: OptimizationRequest, profile: OptimizationProfile) -> AsyncGenerator[int, None]: if profile.launcher and profile.launcher.skip_mapping: self._log.info(f"Skipping launcher mapping for {profile.get_log_str()} (pid: {request.pid})") return @@ -169,7 +223,8 @@ async def map_pid(self, request: OptimizationRequest, profile: OptimizationProfi break if wrapped_target: - return await self._find_wrapped_process(wrapped_target, file_name) + async for pid in self.find_wrapped_process(wrapped_target, file_name, request.pid): + yield pid else: self._log.debug("No valid launchers mapped found") @@ -177,81 +232,226 @@ async def map_pid(self, request: OptimizationRequest, profile: OptimizationProfi class SteamLauncherMapper(LauncherMapper): - def __init__(self, wait_time: float, logger: Logger): - super(SteamLauncherMapper, self).__init__(check_time=wait_time, logger=logger) + def __init__(self, check_time: float, found_check_time: float, logger: Logger, + iteration_sleep_time: float = 0.1): + super(SteamLauncherMapper, self).__init__(check_time=check_time, + found_check_time=found_check_time, + logger=logger) + self._re_steam_cmd: Optional[Pattern] = None + self._to_ignore: Optional[Set[str]] = None # processes that should not be optimized + self._re_proton_command: Optional[Pattern] = None + self._iteration_sleep_time = iteration_sleep_time # used to avoid CPU overloading while looking for targets - async def map_pid(self, request: OptimizationRequest, profile: OptimizationProfile) -> Optional[int]: - if profile.steam: - steam_cmd = steam.get_steam_runtime_command(request.command) + @property + def re_steam_cmd(self) -> Pattern: + if not self._re_steam_cmd: + self._re_steam_cmd = re.compile(r'^.+/(\w+)\s+SteamLaunch\s+.+', re.IGNORECASE) - if not steam_cmd: - self._log.warning(f'Command not from Steam: {request.command} (pid: {request.pid})') - return + return self._re_steam_cmd - self._log.debug(f'Steam command detected (pid: {request.pid}): {request.command}') + @property + def re_proton_command(self) -> Pattern: + if not self._re_proton_command: + self._re_proton_command = re.compile(r'^.+/proton\s+waitforexitandrun\s+.+$', re.IGNORECASE) - proton_name_and_paths = steam.get_proton_exec_name_and_paths(steam_cmd) + return self._re_proton_command - if proton_name_and_paths: - cmd_patterns = {re.compile(r'^{}$'.format(re.escape(cmd))) for cmd in proton_name_and_paths[1:]} - else: - cmd_patterns = {re.compile(r'(/bin/\w+\s+)?{}'.format(re.escape(steam_cmd)))} # native games + @property + def to_ignore(self) -> Set[str]: + if self._to_ignore is None: + self._to_ignore = {"wineserver", "services.exe", "winedevice.exe", "plugplay.exe", "svchost.exe", + "explorer.exe", "rpcss.exe", "tabtip.exe", "wine", "wine64", "wineboot.exe", + "cmd.exe", "conhost.exe", "start.exe", "steam-runtime-l", "proton", "gzip", + "steam.exe", "python", "python3", "OriginWebHelper", "Origin.exe", + "OriginClientSer", "QtWebEngineProc", "EASteamProxy.ex", "ActivationUI.ex", + "EALink.exe", "OriginLegacyCLI", "IGOProxy.exe", "IGOProxy64.exe", "igoproxy64.exe", + "ldconfig", "UPlayBrowser.exe", "whql:off", "PnkBstrA.exe"} - cmd_logs = ', '.join(p.pattern for p in cmd_patterns) - self._log.debug(f'Looking for a Steam process matching one of the command patterns (pid: {request.pid}): {cmd_logs}') + return self._to_ignore - time_init = datetime.now() - time_limit = time_init + timedelta(seconds=self._check_time) - while datetime.now() < time_limit: - proc_found = await system.find_process_by_command(cmd_patterns, last_match=True) - find_time = (datetime.now() - time_init).total_seconds() + async def map_processes_by_parent(self) -> Dict[int, Set[Tuple[int, str]]]: + exitcode, output = await async_syscall(f'ps -Ao "%P#%p#%c" -ww --no-headers') - if proc_found is not None: - self._log.info(f"Steam process '{proc_found[1]}' ({proc_found[0]}) found in {find_time:.2f} seconds") - return proc_found[0] - else: - await asyncio.sleep(0.001) + if exitcode == 0 and output: + proc_tree = dict() - find_time = (datetime.now() - time_init).total_seconds() - self._log.warning(f'Could not find a Steam process matching command patterns (pid: {request.pid}). Search timed out in {find_time:.2f} seconds') + for line in output.split("\n"): + line_strip = line.strip() - if proton_name_and_paths: - proc_name = proton_name_and_paths[0] - else: - proc_name = get_exe_name(steam_cmd) + if line_strip: + line_split = line_strip.split('#', 2) + + if len(line_split) > 2: + ppid, pid, comm, = (e.strip() for e in line_split) + try: + ppid, pid = int(ppid), int(pid) + except ValueError: + continue + + children = proc_tree.get(ppid) + + if not children: + children = set() + proc_tree[ppid] = children + + children.add((pid, comm)) + + return proc_tree + + def find_target_in_hierarchy(self, reverse_hierarchy: List[str], root_element_pid: int, + processes_by_parent: Optional[Dict[int, Set[Tuple[int, str]]]] = None, + pid_by_comm: Optional[Dict[str, int]] = None) -> Optional[int]: + + if len(reverse_hierarchy) == 1: + return root_element_pid + + comm_pid = dict() if pid_by_comm is None else pid_by_comm + + if reverse_hierarchy[-1] not in comm_pid: + comm_pid[reverse_hierarchy[-1]] = root_element_pid + + for idx, comm in enumerate(reverse_hierarchy): + pid = comm_pid.get(comm) + + if pid is None: + parent_id = comm_pid.get(reverse_hierarchy[idx + 1]) + + if not parent_id: + continue # the iteration must continue if the parent id is not mapped yet - if not proc_name: - self._log.warning(f'Name of launched Steam command could not be determined (request={request.pid}). No extra search will be performed.') + parent_children = processes_by_parent.get(parent_id) + + if not parent_children: + return # if the parent has no children, it will not be possible to find the current comm's pid + + try: + pid = next(pid_ for pid_, comm_ in sorted(parent_children, reverse=True) if comm_ == comm) + except StopIteration: + return # the current comm could not be found, so stop the iteration + + comm_pid[comm] = pid + + if idx == 0: # if current element is the target, return it immediately + return pid + + # restart the find + return self.find_target_in_hierarchy(reverse_hierarchy=reverse_hierarchy, + root_element_pid=root_element_pid, + processes_by_parent=processes_by_parent, + pid_by_comm=comm_pid) + + def map_expected_hierarchy(self, request: OptimizationRequest, root_comm: Optional[str] = None) -> List[str]: + hierarchy = list() + + if "/steamapps/common/SteamLinux" in request.command: + self._log.debug(f"Steam command comes from container (pid: {request.pid})") + hierarchy.append("pressure-vessel") + hierarchy.append("pv-bwrap") + elif self.re_proton_command.match(request.command): + hierarchy.append("python3") + + if root_comm: + hierarchy.append(root_comm) + + return hierarchy + + def extract_root_process_name(self, command: str) -> str: + root_cmd = self.re_steam_cmd.findall(command) + + if root_cmd: + return root_cmd[0] + + def find_children(self, ppid: int, processes_by_parent: Dict[int, Set[Tuple[int, str]]], + to_ignore: Optional[Set[str]] = None, already_found: Optional[Set[int]] = None) \ + -> Generator[int, None, None]: + + found = already_found if already_found is not None else set() + + children = processes_by_parent.get(ppid) + + if children: + for pid, comm in children: + if (not to_ignore or comm not in to_ignore) and "" not in comm: + if pid not in found: + self._log.info(f"Steam child process found: {comm} (pid={pid}, ppid={ppid})") + yield pid + found.add(pid) + + for pid_ in self.find_children(ppid=pid, processes_by_parent=processes_by_parent, + to_ignore=to_ignore, already_found=found): + yield pid_ + + async def map_pids(self, request: OptimizationRequest, profile: OptimizationProfile) -> AsyncGenerator[int, None]: + if profile.steam: + steam_root_comm = self.extract_root_process_name(request.command) + + if not steam_root_comm: + self._log.warning(f'Command not from Steam: {request.command} (pid: {request.pid})') else: - self._log.debug(f"Trying to find Steam process by name '{proc_name}' (request: {request.pid})") - ti = time.time() - proc_found = await system.find_process_by_name(re.compile(r'^{}$'.format(proc_name)), last_match=True) - tf = time.time() + self._log.debug(f'Steam command detected for request (pid: {request.pid})') + expected_hierarchy = self.map_expected_hierarchy(request, steam_root_comm) + timeout = datetime.now() + timedelta(seconds=self._check_time) - if proc_found: - self._log.info(f"Steam process named '{proc_found[0]}' ({proc_found[1]}) found in {tf - ti:.2f} seconds") - return proc_found[0] - else: - self._log.warning(f'Could not find a Steam process named {proc_name} (request={request.pid})') + latest_found_timeout = None # timeout for every time a target is found (to stop faster) + pid_by_comm = dict() # to save which processes were previously mapped + already_found: Set[int] = set() # processes already yielded + target_ppid = None # parent with the target children + to_ignore = {*expected_hierarchy, *self.to_ignore} # target children to ignore -class LauncherMapperManager(LauncherMapper): + while datetime.now() < timeout: + if latest_found_timeout and datetime.now() >= latest_found_timeout: + self._log.debug(f"Steam subprocesses search timed out earlier (source_pid={request.pid})") + return + + parent_procs = await self.map_processes_by_parent() + + if target_ppid is None: + target_ppid = self.find_target_in_hierarchy(reverse_hierarchy=expected_hierarchy, + root_element_pid=request.pid, + processes_by_parent=parent_procs, + pid_by_comm=pid_by_comm) + if target_ppid is not None: + self._log.debug(f"Target Steam process parent found (pid={target_ppid}, " + f"comm={expected_hierarchy[0]}) (source_pid={request.pid})") - def __init__(self, check_time: float, logger: Logger, mappers: Optional[List[LauncherMapper]] = None): - super(LauncherMapperManager, self).__init__(check_time, logger) + if target_ppid is not None: + for pid in self.find_children(ppid=target_ppid, processes_by_parent=parent_procs, + already_found=already_found, to_ignore=to_ignore): + if self._found_check_time >= 0: + latest_found_timeout = datetime.now() + timedelta(seconds=self._found_check_time) + + yield pid + + if self._iteration_sleep_time > 0: + await asyncio.sleep(self._iteration_sleep_time) + + self._log.debug(f"Steam subprocesses search timed out (source_pid={request.pid})") + + +class LauncherMapperManager(LauncherMapper): + def __init__(self, check_time: float, found_check_time: float, + logger: Logger, mappers: Optional[Tuple[LauncherMapper, ...]] = None): + super(LauncherMapperManager, self).__init__(check_time, found_check_time, logger) if mappers: self._sub_mappers = mappers else: - self._sub_mappers = [cls(check_time, logger) for cls in LauncherMapper.__subclasses__() if cls != self.__class__] + sub_classes = LauncherMapper.__subclasses__() + self._sub_mappers = tuple(cls(check_time, found_check_time, logger) for cls in sub_classes + if cls != self.__class__) - async def map_pid(self, request: OptimizationRequest, profile: OptimizationProfile) -> Optional[int]: + async def map_pids(self, request: OptimizationRequest, profile: OptimizationProfile) -> AsyncGenerator[int, None]: + any_mapper_yield = False for mapper in self._sub_mappers: - real_id = await mapper.map_pid(request, profile) + if any_mapper_yield: # if any mapper already returned something, stop the iteration + return - if real_id: - return real_id + async for real_id in mapper.map_pids(request, profile): + if real_id is not None: + any_mapper_yield = True + yield real_id - def get_sub_mappers(self) -> Optional[List[LauncherMapper]]: + def get_sub_mappers(self) -> Optional[Tuple[LauncherMapper]]: if self._sub_mappers is not None: - return [*self._sub_mappers] + return tuple(self._sub_mappers) diff --git a/guapow/service/optimizer/main.py b/guapow/service/optimizer/main.py index 7ceb8e2..7905099 100644 --- a/guapow/service/optimizer/main.py +++ b/guapow/service/optimizer/main.py @@ -54,6 +54,7 @@ async def prepare_app() -> Tuple[web.Application, OptimizerConfig]: logger.info(f"Nice levels monitoring interval: {opt_config.renicer_interval} seconds") logger.info(f'Finished process checking interval: {opt_config.check_finished_interval} seconds') logger.info(f'Launcher mapping timeout: {opt_config.launcher_mapping_timeout} seconds') + logger.info(f'Launcher mapping found timeout: {opt_config.launcher_mapping_found_timeout} seconds') if opt_config.allow_root_scripts: logger.warning("Scripts are allowed to run at root level") @@ -93,8 +94,9 @@ async def prepare_app() -> Tuple[web.Application, OptimizerConfig]: context = OptimizationContext(cpufreq_man=cpufreq_man, gpu_man=gpu_man, logger=logger, cpu_count=cpu_count, compositor=compositor, allow_root_scripts=bool(opt_config.allow_root_scripts), launcher_mapping_timeout=opt_config.launcher_mapping_timeout, + launcher_mapping_found_timeout=opt_config.launcher_mapping_found_timeout, mouse_man=MouseCursorManager(logger), renicer_interval=opt_config.renicer_interval, - cpuenergy_man=cpu_energy_man, queue=OptimizationQueue.empty(), + cpuenergy_man=cpu_energy_man, queue=OptimizationQueue(set(), logger), system_service=opt_config.is_service(), gpu_ids={str(i) for i in opt_config.gpu_ids} if opt_config.gpu_ids else None) diff --git a/guapow/service/optimizer/post_process/summary.py b/guapow/service/optimizer/post_process/summary.py index de1d248..cc9416f 100644 --- a/guapow/service/optimizer/post_process/summary.py +++ b/guapow/service/optimizer/post_process/summary.py @@ -37,7 +37,7 @@ def __init__(self, pids_alive: Optional[Set[int]], user_id: Optional[int], user_ self.restore_cpu_energy_policy = restore_cpu_energy_policy @classmethod - def empyt(cls) -> "PostProcessSummary": + def empty(cls) -> "PostProcessSummary": return cls(pids_alive=None, user_id=None, user_env=None, restore_compositor=None, previous_cpu_states=None, cpus_in_use=None, previous_gpus_states=None, pids_to_stop=None, processes_relaunch_by_time=None, post_scripts=None, gpus_in_use=None, keep_compositor_disabled=None, processes_not_relaunch=None, @@ -224,7 +224,7 @@ async def fill(self, summary: PostProcessSummary, process: OptimizedProcess, mai await analyser.fill(summary, process, main_context) async def summarize(self, processes: List[OptimizedProcess], pids_alive: Set[int], processes_to_relaunch: Optional[Dict[str, Optional[str]]], context: OptimizationContext) -> PostProcessSummary: - summary = PostProcessSummary.empyt() + summary = PostProcessSummary.empty() summary.processes_to_relaunch = processes_to_relaunch summary.pids_alive = pids_alive diff --git a/guapow/service/optimizer/task/manager.py b/guapow/service/optimizer/task/manager.py index 42962d3..f5916e3 100644 --- a/guapow/service/optimizer/task/manager.py +++ b/guapow/service/optimizer/task/manager.py @@ -1,5 +1,5 @@ import asyncio -from typing import Optional, List, Awaitable, Type, Dict +from typing import Optional, List, Awaitable, Type, Dict, Tuple, Iterable from guapow.service.optimizer.task.environment import EnvironmentTask, DisableWindowCompositor, \ ChangeCPUFrequencyGovernor, ChangeGPUModeToPerformance, HideMouseCursor, StopProcessesAfterLaunch, \ @@ -9,9 +9,9 @@ ChangeCPUScalingPolicy, ChangeProcessIOClass -def run_tasks(tasks: List[Task], process: OptimizedProcess) -> Optional[List[Awaitable]]: +def run_tasks(tasks: Iterable[Task], process: OptimizedProcess) -> Optional[Tuple[Awaitable, ...]]: if tasks: - return [t.run(process) for t in tasks] + return tuple(t.run(process) for t in tasks) class TasksManager: @@ -72,7 +72,7 @@ async def check_availability(self): self._log.debug(f"Environment tasks available ({len(self._env_tasks)}): {', '.join([t.__class__.__name__ for t in self._env_tasks])}") self._env_tasks.sort(key=self._sort_env) - async def get_available_process_tasks(self, process: OptimizedProcess) -> Optional[List[ProcessTask]]: + async def get_available_process_tasks(self, process: OptimizedProcess) -> Optional[Iterable[ProcessTask]]: if self._proc_tasks: return await self._list_runnable_tasks(process, self._proc_tasks) @@ -80,14 +80,17 @@ async def get_available_environment_tasks(self, process: OptimizedProcess) -> Op if self._env_tasks: return await self._list_runnable_tasks(process, self._env_tasks) - async def _list_runnable_tasks(self, process: OptimizedProcess, tasks: List[Task]) -> Optional[List[Task]]: - to_verify = [t for t in tasks if t.is_allowed_for_self_requests()] if process.request.is_self_request else tasks + async def _list_runnable_tasks(self, process: OptimizedProcess, tasks: List[Task]) -> Optional[Tuple[Task, ...]]: + if process.request.is_self_request: + to_verify = tuple(t for t in tasks if t.is_allowed_for_self_requests()) + else: + to_verify = tasks if to_verify: - async_tasks = [self._should_run(task, process) for task in to_verify] + async_tasks = tuple(self._should_run(task, process) for task in to_verify) if async_tasks: - return [t for t in await asyncio.gather(*async_tasks) if t] + return tuple(t for t in await asyncio.gather(*async_tasks) if t) async def _should_run(self, task: Task, process: OptimizedProcess) -> Optional[Task]: if await task.should_run(process): diff --git a/guapow/service/optimizer/task/model.py b/guapow/service/optimizer/task/model.py index 25b9fed..4124696 100644 --- a/guapow/service/optimizer/task/model.py +++ b/guapow/service/optimizer/task/model.py @@ -17,7 +17,7 @@ class OptimizationContext: def __init__(self, gpu_man: Optional[GPUManager], logger: Optional[Logger], cpufreq_man: Optional[CPUFrequencyManager], cpuenergy_man: Optional[CPUEnergyPolicyManager], mouse_man: Optional[MouseCursorManager], queue: Optional[OptimizationQueue], cpu_count: int, - launcher_mapping_timeout: float, renicer_interval: float, + launcher_mapping_timeout: float, launcher_mapping_found_timeout: float, renicer_interval: float, compositor: Optional[WindowCompositor] = None, allow_root_scripts: bool = False, compositor_disabled_context: Optional[dict] = None, system_service: bool = False, gpu_ids: Optional[Set[str]] = None): @@ -32,6 +32,7 @@ def __init__(self, gpu_man: Optional[GPUManager], logger: Optional[Logger], self.compositor = compositor self.allow_root_scripts = allow_root_scripts self.launcher_mapping_timeout = launcher_mapping_timeout + self.launcher_mapping_found_timeout = launcher_mapping_found_timeout self.compositor_disabled_context = compositor_disabled_context # if the compositor was disabled by the Optimizer self.renicer_interval = renicer_interval self.system_service = system_service @@ -40,7 +41,8 @@ def __init__(self, gpu_man: Optional[GPUManager], logger: Optional[Logger], @classmethod def empty(cls) -> "OptimizationContext": return cls(gpu_man=None, mouse_man=None, logger=None, cpufreq_man=None, queue=None, - cpu_count=0, launcher_mapping_timeout=0, renicer_interval=0, cpuenergy_man=None) + cpu_count=0, launcher_mapping_timeout=0, renicer_interval=0, cpuenergy_man=None, + launcher_mapping_found_timeout=0) async def is_mouse_cursor_hidden(self) -> Optional[bool]: return await self.mouse_man.is_cursor_hidden() if self.mouse_man else None @@ -69,20 +71,30 @@ class OptimizedProcess: def __init__(self, request: OptimizationRequest, created_at: float, profile: Optional[OptimizationProfile] = None, previous_gpus_states: Optional[Dict[Type[GPUDriver], Set[GPUState]]] = None, previous_cpu_state: Optional[CPUState] = None, stopped_after_launch: Optional[Dict[str, str]] = None, - cpu_energy_policy_changed: bool = False): + cpu_energy_policy_changed: bool = False, alive: bool = True, related_pids: Optional[Set[int]] = None, + pid: Optional[int] = None): self.created_at = created_at self.request = request self.profile = profile self.previous_gpus_states = previous_gpus_states self.previous_cpu_state = previous_cpu_state self.stopped_after_launch = stopped_after_launch - self.alive = True - self.related_pids = {*self.request.related_pids} if self.request and self.request.related_pids else set() - self.pid = request.pid if self.request else None + self.alive = alive + + if related_pids is not None: + self.related_pids = related_pids + else: + self.related_pids = {*self.request.related_pids} if self.request and self.request.related_pids else set() + + if pid is not None: + self.pid = pid + else: + self.pid = request.pid if self.request else None + self.cpu_energy_policy_changed = cpu_energy_policy_changed def should_be_watched(self) -> bool: - return bool(self.pid is not None and any([self.related_pids, + return bool(self.pid is not None and any((self.related_pids, self.previous_cpu_state, self.previous_gpus_states, self.post_scripts, @@ -90,7 +102,7 @@ def should_be_watched(self) -> bool: self.stopped_processes, self.requires_mouse_hidden, self.stopped_after_launch, - self.cpu_energy_policy_changed])) + self.cpu_energy_policy_changed))) @property def source_pid(self) -> Optional[int]: @@ -143,27 +155,21 @@ def get_pids(self) -> Optional[Set[int]]: return pids - def __eq__(self, other): - if not isinstance(other, OptimizedProcess): - return False - - for p, v in self.__dict__.items(): - if v != getattr(other, p): - return False + def __eq__(self, other) -> bool: + if isinstance(other, OptimizedProcess): + return self.__dict__ == other.__dict__ - return True - - def __hash__(self): - hash_sum = 0 - - for _, v in sorted(self.__dict__.items()): - hash_sum += hash(v) + return False - return hash_sum + def __hash__(self) -> int: + return sum(hash(v) for v in self.__dict__.values()) def __repr__(self): return f'{self.__class__.__name__} {self.__dict__}' + def clone(self) -> "OptimizedProcess": + return OptimizedProcess(**self.__dict__) + class Task(ABC): diff --git a/tests/__init__.py b/tests/__init__.py index f9962d9..35a7184 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,6 @@ +import asyncio import os -from typing import Iterable +from typing import Iterable, Optional, Any, List TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) RESOURCES_DIR = f'{TESTS_DIR}/resources' @@ -28,3 +29,16 @@ async def __anext__(self): return next(self.iter) except StopIteration: raise StopAsyncIteration + + +class MockedAsyncCall: + def __init__(self, results: List[Any], await_time: Optional[float] = None): + self._results = results + self._iterable = iter(results) + self._await_time = await_time if isinstance(await_time, (int, float)) and await_time > 0 else None + + async def call(self, *args, **kwargs) -> Any: + if self._await_time: + await asyncio.sleep(self._await_time) + + return next(self._iterable, self._results[-1]) diff --git a/tests/common/config/test_optimizer_config.py b/tests/common/config/test_optimizer_config.py index 8dae774..1adb654 100644 --- a/tests/common/config/test_optimizer_config.py +++ b/tests/common/config/test_optimizer_config.py @@ -189,9 +189,13 @@ def test_default__allow_root_scripts_must_be_false(self): instance = OptimizerConfig.default() self.assertEqual(False, instance.allow_root_scripts) - def test_default__launcher_mapping_timeout_must_be_30_seconds(self): + def test_default__launcher_mapping_timeout_must_be_60_seconds(self): instance = OptimizerConfig.default() - self.assertEqual(30, instance.launcher_mapping_timeout) + self.assertEqual(60, instance.launcher_mapping_timeout) + + def test_default__launcher_mapping_found_timeout_must_be_10_seconds(self): + instance = OptimizerConfig.default() + self.assertEqual(10, instance.launcher_mapping_found_timeout) def test_default__check_finished_interval_must_be_3_seconds(self): instance = OptimizerConfig.default() diff --git a/tests/common/config/test_optimizer_config_reader.py b/tests/common/config/test_optimizer_config_reader.py index 98d31a3..cf5c247 100644 --- a/tests/common/config/test_optimizer_config_reader.py +++ b/tests/common/config/test_optimizer_config_reader.py @@ -25,7 +25,7 @@ async def test_read_valid__return_valid_instance_when_no_existent_property_is_de self.assertFalse(config.gpu_cache) self.assertEqual(3, config.check_finished_interval) self.assertFalse(config.allow_root_scripts) - self.assertEqual(30, config.launcher_mapping_timeout) + self.assertEqual(60, config.launcher_mapping_timeout) self.assertIsNotNone(config.request) self.assertTrue(config.request.encrypted) self.assertIsNone(config.request.allowed_users) @@ -97,7 +97,7 @@ async def test_read_valid__return_instance_with_default_real_cmd_check_time_when file_path = f'{RESOURCES_DIR}/opt_compositor.conf' config = await self.reader.read_valid(file_path=file_path) self.assertIsNotNone(config) - self.assertEqual(30, config.launcher_mapping_timeout) + self.assertEqual(60, config.launcher_mapping_timeout) async def test_read_valid__return_instance_with_valid_launcher_mapping_timeout_defined(self): file_path = f'{RESOURCES_DIR}/opt_launcher_timeout.conf' @@ -105,6 +105,12 @@ async def test_read_valid__return_instance_with_valid_launcher_mapping_timeout_d self.assertIsNotNone(config) self.assertEqual(0.5, config.launcher_mapping_timeout) + async def test_read_valid__return_instance_with_valid_launcher_mapping_found_timeout_defined(self): + file_path = f'{RESOURCES_DIR}/opt_launcher_found_timeout.conf' + config = await self.reader.read_valid(file_path=file_path) + self.assertIsNotNone(config) + self.assertEqual(0.5, config.launcher_mapping_found_timeout) + async def test_read_valid__return_instance_with_valid_gpu_cache(self): file_path = f'{RESOURCES_DIR}/opt_gpu_cache.conf' config = await self.reader.read_valid(file_path=file_path) diff --git a/tests/common/test_util.py b/tests/common/test_util.py index 848996d..f578a16 100644 --- a/tests/common/test_util.py +++ b/tests/common/test_util.py @@ -1,98 +1,10 @@ from re import Pattern from unittest import TestCase -from guapow.common import steam, util +from guapow.common import util -class GetProtonExecNameAndPathsTest(TestCase): - - def test__must_return_valid_path_for_runtime_cmd(self): - cmd = '/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point --verb=waitforexitandrun -- /home/user/.local/share/Steam/steamapps/common/Proton 5.13/proton waitforexitandrun /home/user/.local/share/Steam/steamapps/common/Abc Bcd/AbcBcd.exe' - expected = ('AbcBcd.exe', - 'Z:\\home\\user\\.local\\share\\Steam\\steamapps\\common\\Abc Bcd\\AbcBcd.exe', - '/home/user/.local/share/Steam/steamapps/common/Abc Bcd/AbcBcd.exe') - actual = steam.get_proton_exec_name_and_paths(cmd) - self.assertEqual(expected, actual) - - def test__must_return_valid_name_for_exe_with_spaces_dots_and_params(self): - cmd = '/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point --verb=waitforexitandrun -- /home/user/.local/share/Steam/steamapps/common/Proton 5.13/proton waitforexitandrun /home/user/.local/share/Steam/steamapps/common/Abc Bcd/Abc T51_Bcd.123.exe -xpto 1' - expected = ('Abc T51_Bcd.123.exe', - 'Z:\\home\\user\\.local\\share\\Steam\\steamapps\\common\\Abc Bcd\\Abc T51_Bcd.123.exe -xpto 1', - '/home/user/.local/share/Steam/steamapps/common/Abc Bcd/Abc T51_Bcd.123.exe -xpto 1') - actual = steam.get_proton_exec_name_and_paths(cmd) - self.assertEqual(expected, actual) - - def test__must_return_valid_path_for_proton_3_7_cmd(self): - cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=443860 -- /home/user/.local/share/Steam/steamapps/common/Proton 3.7/proton waitforexitandrun /home/user/.local/share/Steam/steamapps/common/My Game II/GameData/GameII.exe' - expected = ('GameII.exe', - 'Z:\\home\\user\\.local\\share\\Steam\\steamapps\\common\\My Game II\\GameData\\GameII.exe', - '/home/user/.local/share/Steam/steamapps/common/My Game II/GameData/GameII.exe') - actual = steam.get_proton_exec_name_and_paths(cmd) - self.assertEqual(expected, actual) - - def test__must_return_valid_path_for_proton_3_16_cmd(self): - cmd = 'home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- /home/user/.local/share/Steam/steamapps/common/Proton 3.16/proton waitforexitandrun /home/user/.local/share/Steam/steamapps/common/My Game II/GameData/GameII.exe' - expected = ('GameII.exe', - 'Z:\\home\\user\\.local\\share\\Steam\\steamapps\\common\\My Game II\\GameData\\GameII.exe', - '/home/user/.local/share/Steam/steamapps/common/My Game II/GameData/GameII.exe') - actual = steam.get_proton_exec_name_and_paths(cmd) - self.assertEqual(expected, actual) - - def test__must_return_valid_path_for_proton_4_2_cmd(self): - cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- /home/user/.local/share/Steam/steamapps/common/Proton 4.2/proton waitforexitandrun /home/user/.local/share/Steam/steamapps/common/My Game/Game_x64.exe' - expected = ('Game_x64.exe', - 'Z:\\home\\user\\.local\\share\\Steam\\steamapps\\common\\My Game\\Game_x64.exe', - '/home/user/.local/share/Steam/steamapps/common/My Game/Game_x64.exe') - actual = steam.get_proton_exec_name_and_paths(cmd) - self.assertEqual(expected, actual) - - def test__must_return_valid_path_for_proton_4_11_cmd(self): - cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- /home/user/.local/share/Steam/steamapps/common/Proton 4.11/proton waitforexitandrun /home/user/.local/share/Steam/steamapps/common/My Game/Game_x64.exe' - expected = ('Game_x64.exe', - 'Z:\\home\\user\\.local\\share\\Steam\\steamapps\\common\\My Game\\Game_x64.exe', - '/home/user/.local/share/Steam/steamapps/common/My Game/Game_x64.exe') - - actual = steam.get_proton_exec_name_and_paths(cmd) - self.assertEqual(expected, actual) - - def test__must_return_valid_path_for_proton_5_0_cmd(self): - cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- /home/user/.local/share/Steam/steamapps/common/Proton 5.0/proton waitforexitandrun /home/user/.local/share/Steam/steamapps/common/My Game/Game_x64.exe' - expected = ('Game_x64.exe', - 'Z:\\home\\user\\.local\\share\\Steam\\steamapps\\common\\My Game\\Game_x64.exe', - '/home/user/.local/share/Steam/steamapps/common/My Game/Game_x64.exe') - actual = steam.get_proton_exec_name_and_paths(cmd) - self.assertEqual(expected, actual) - - def test__must_return_valid_path_for_proton_5_13_cmd(self): - cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- /home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point --verb=waitforexitandrun -- /home/user/.local/share/Steam/steamapps/common/Proton 5.13/proton waitforexitandrun /home/user/.local/share/Steam/steamapps/common/My Game/Game_x64.exe' - expected = ('Game_x64.exe', - 'Z:\\home\\user\\.local\\share\\Steam\\steamapps\\common\\My Game\\Game_x64.exe', - '/home/user/.local/share/Steam/steamapps/common/My Game/Game_x64.exe') - actual = steam.get_proton_exec_name_and_paths(cmd) - self.assertEqual(expected, actual) - - def test__must_return_valid_path_for_proton_6_3_cmd(self): - cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- /home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point --verb=waitforexitandrun -- /home/user/.local/share/Steam/steamapps/common/Proton 6.3/proton waitforexitandrun /home/user/.local/share/Steam/steamapps/common/My Game/Game_x64.exe' - expected = ('Game_x64.exe', - 'Z:\\home\\user\\.local\\share\\Steam\\steamapps\\common\\My Game\\Game_x64.exe', - '/home/user/.local/share/Steam/steamapps/common/My Game/Game_x64.exe') - actual = steam.get_proton_exec_name_and_paths(cmd) - self.assertEqual(expected, actual) - - def test__must_return_valid_path_for_proton_ge_cmd(self): - cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=443860 -- /home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point --verb=waitforexitandrun -- /home/user/.local/share/Steam/compatibilitytools.d/Proton-6.9-GE-2/proton waitforexitandrun /home/user/.local/share/Steam/steamapps/common/My Game/Game_x64.exe' - expected = ('Game_x64.exe', - 'Z:\\home\\user\\.local\\share\\Steam\\steamapps\\common\\My Game\\Game_x64.exe', - '/home/user/.local/share/Steam/steamapps/common/My Game/Game_x64.exe') - actual = steam.get_proton_exec_name_and_paths(cmd) - self.assertEqual(expected, actual) - - def test__must_return_none_when_path_not_from_proton(self): - cmd = '/home/user/.local/share/Steam/steamapps/common/My Game/MyGame.exe' - self.assertIsNone(steam.get_proton_exec_name_and_paths(cmd)) - - -class MapAnyRegex(TestCase): +class MapAnyRegexTest(TestCase): def test_map_any_regex__should_preserve_str_when_no_asterisk(self): regex = util.map_any_regex(' abc ') @@ -104,14 +16,14 @@ def test_map_any_regex__should_replace_several_asterisk_by_equivalent_regex(self regex = util.map_any_regex(' *****ab****c**** ') self.assertIsNotNone(regex) self.assertIsInstance(regex, Pattern) - self.assertEqual(r'^\ .+ab.+c.+\ $', regex.pattern) + self.assertEqual(r'^\ .*ab.*c.*\ $', regex.pattern) self.assertIsNotNone(regex.match(' xptoab tralalac pqpwq ')) def test_map_any_regex__should_escape_backlashes(self): regex = util.map_any_regex("\*Win64\MVCI.exe") self.assertIsNotNone(regex) self.assertIsInstance(regex, Pattern) - self.assertEqual(r'^\\.+Win64\\MVCI\.exe$', regex.pattern) + self.assertEqual(r'^\\.*Win64\\MVCI\.exe$', regex.pattern) self.assertIsNotNone(regex.match(r'\path\to\Win64\MVCI.exe')) diff --git a/tests/resources/opt_launcher_found_timeout.conf b/tests/resources/opt_launcher_found_timeout.conf new file mode 100644 index 0000000..6a8c72b --- /dev/null +++ b/tests/resources/opt_launcher_found_timeout.conf @@ -0,0 +1 @@ +launcher.mapping.found_timeout=0.5 diff --git a/tests/service/optimizer/optimization/test_model.py b/tests/service/optimizer/optimization/test_model.py index 5a756ac..0fe665c 100644 --- a/tests/service/optimizer/optimization/test_model.py +++ b/tests/service/optimizer/optimization/test_model.py @@ -3,8 +3,8 @@ from guapow.common.dto import OptimizationRequest from guapow.common.model import ScriptSettings from guapow.service.optimizer.gpu import AMDGPUDriver, GPUState, NvidiaPowerMode +from guapow.service.optimizer.profile import OptimizationProfile, CompositorSettings from guapow.service.optimizer.task.model import OptimizedProcess, CPUState -from guapow.service.optimizer.profile import OptimizationProfile, CompositorSettings, CPUSettings class OptimizedProcessTest(TestCase): @@ -86,20 +86,50 @@ def test_should_be_watched__true_when_cpu_energy_policy_changed(self): self.proc.cpu_energy_policy_changed = True self.assertTrue(self.proc.should_be_watched()) - def get_display__must_return_zero_when_no_user_env_is_defined(self): + def test_get_display__must_return_zero_when_no_user_env_is_defined(self): proc = OptimizedProcess(OptimizationRequest.self_request(), 123) self.assertEqual(':0', proc.get_display()) - def get_display__must_return_zero_when_user_env_has_no_DISPLAY_var(self): + def test_get_display__must_return_zero_when_user_env_has_no_DISPLAY_var(self): req = OptimizationRequest.self_request() req.user_env = {'a': 1} proc = OptimizedProcess(req, 123) self.assertEqual(':0', proc.get_display()) - def get_display__must_return_user_env_DISPLAY_var_value(self): + def test_get_display__must_return_user_env_DISPLAY_var_value(self): req = OptimizationRequest.self_request() req.user_env = {'DISPLAY': ':1'} proc = OptimizedProcess(req, 123) self.assertEqual(':1', proc.get_display()) + + def test_clone__returned_instance_must_be_equal_the_source(self): + self.proc.previous_cpu_state = CPUState({'schedutil': {1}}) + self.proc.previous_gpus_states = {AMDGPUDriver: {GPUState('0', AMDGPUDriver, NvidiaPowerMode.ON_DEMAND)}} + self.proc.cpu_energy_policy_changed = True + self.profile.hide_mouse = True + self.request.related_pids = {456, 789} + + clone = self.proc.clone() + self.assertEqual(self.proc, clone) + + # checking every attribute to make sure + for attr, val in self.proc.__dict__.items(): + self.assertEqual(val, getattr(clone, attr)) + + self.assertNotEqual(id(clone), id(self.proc)) # must not have the same memory address + + def test_clone__returned_instance_pid_change_must_not_reflect_on_the_source(self): + clone = self.proc.clone() + clone.pid = 525267 + self.assertNotEqual(self.proc.pid, clone.pid) + self.assertNotEqual(self.proc, clone) + self.assertEqual(self.proc.source_pid, clone.source_pid) # source pid must not be changed + + def test_clone__returned_instance_must_be_watchable_if_source_is_watchable(self): + self.proc.previous_cpu_state = CPUState({'schedutil': {1}}) + self.assertTrue(self.proc.should_be_watched()) + + clone = self.proc.clone() + self.assertTrue(clone.should_be_watched()) diff --git a/tests/service/optimizer/post_process/test_context.py b/tests/service/optimizer/post_process/test_context.py index 2ecb9c6..ee914b4 100644 --- a/tests/service/optimizer/post_process/test_context.py +++ b/tests/service/optimizer/post_process/test_context.py @@ -10,7 +10,7 @@ class SortedProcessesToRelaunchTest(TestCase): def setUp(self): self.task = SortedProcessesToRelaunchFiller() - self.post_summary = PostProcessSummary.empyt() + self.post_summary = PostProcessSummary.empty() self.context = PostProcessContext.empty() def test_fill__must_not_add_duplicate_comm_cmds_to_the_context(self): @@ -49,7 +49,7 @@ class RestorableCPUEnergyPolicyLevelFillerTest(TestCase): def setUp(self): self.context = PostProcessContext.empty() - self.summary = PostProcessSummary.empyt() + self.summary = PostProcessSummary.empty() self.task = RestorableCPUEnergyPolicyLevelFiller() def test_fill__must_set_restore_cpu_energy_policy_to_true_if_not_keep_and_restore_true(self): diff --git a/tests/service/optimizer/test_handler.py b/tests/service/optimizer/test_handler.py index 1268e6d..07e683c 100644 --- a/tests/service/optimizer/test_handler.py +++ b/tests/service/optimizer/test_handler.py @@ -4,12 +4,12 @@ from guapow import __app_name__ from guapow.common.dto import OptimizationRequest from guapow.common.model_util import FileModelFiller +from guapow.service.optimizer.flow import OptimizationQueue from guapow.service.optimizer.handler import OptimizationHandler from guapow.service.optimizer.profile import OptimizationProfile, CPUSettings, OptimizationProfileReader, \ ProcessSettings, ProcessNiceSettings -from guapow.service.optimizer.flow import OptimizationQueue from guapow.service.optimizer.task.model import OptimizationContext, OptimizedProcess -from tests import RESOURCES_DIR +from tests import RESOURCES_DIR, AsyncIterator def get_test_user_profile_path(name: str, user_name: str): @@ -95,7 +95,9 @@ async def test_handle__DISPLAY_env_var_must_always_be_present_for_every_request( @patch('os.path.exists', return_value=True) @patch(f'{__app_name__}.common.profile.get_user_profile_path', side_effect=get_test_user_profile_path) @patch(f'{__app_name__}.service.optimizer.handler.run_tasks', return_value=None) - async def test_handle__must_load_profile_from_home_dir_when_request_from_non_root_user(self, run_tasks: Mock, get_user_profile_path: Mock, os_path_exists: Mock, time: Mock): + async def test_handle__must_load_profile_from_home_dir_when_request_from_non_root_user(self, *mocks: Mock): + run_tasks, get_user_profile_path, os_path_exists, time = mocks[0], mocks[1], mocks[2], mocks[3] + tasks_man = MagicMock() tasks_man.get_available_environment_tasks = AsyncMock(return_value=None) tasks_man.get_available_process_tasks = AsyncMock(return_value=None) @@ -124,7 +126,9 @@ async def test_handle__must_load_profile_from_home_dir_when_request_from_non_roo @patch('os.path.exists', return_value=True) @patch(f'{__app_name__}.common.profile.get_user_profile_path', side_effect=get_test_user_profile_path) @patch(f'{__app_name__}.service.optimizer.handler.run_tasks', return_value=None) - async def test_handle__must_load_profile_from_home_dir_when_request_from_non_root_user(self, run_tasks: Mock, get_user_profile_path: Mock, os_path_exists: Mock, time: Mock): + async def test_handle__must_load_profile_from_home_dir_when_request_from_non_root_user(self, *mocks: Mock): + run_tasks, get_user_profile_path, os_path_exists, time = mocks[0], mocks[1], mocks[2], mocks[3] + tasks_man = MagicMock() tasks_man.get_available_environment_tasks = AsyncMock(return_value=None) tasks_man.get_available_process_tasks = AsyncMock(return_value=None) @@ -154,7 +158,10 @@ async def test_handle__must_load_profile_from_home_dir_when_request_from_non_roo @patch(f'{__app_name__}.common.profile.get_user_profile_path', side_effect=get_test_user_profile_path) @patch(f'{__app_name__}.common.profile.get_root_profile_path', side_effect=get_test_root_profile_path) @patch(f'{__app_name__}.service.optimizer.handler.run_tasks', return_value=None) - async def test_handle__must_load_profile_from_root_dir_when_request_from_non_root_user_but_user_file_not_exist(self, run_tasks: Mock, get_root_profile_path: Mock, get_user_profile_path: Mock, os_path_exists: Mock, time: Mock): + async def test_handle__must_load_profile_from_root_dir_when_request_from_non_root_user_but_user_file_not_exist(self, *mocks: Mock): + run_tasks, get_root_profile_path, get_user_profile_path = mocks[0], mocks[1], mocks[2] + os_path_exists, time = mocks[3], mocks[4] + tasks_man = MagicMock() tasks_man.get_available_environment_tasks = AsyncMock(return_value=None) tasks_man.get_available_process_tasks = AsyncMock(return_value=None) @@ -185,7 +192,10 @@ async def test_handle__must_load_profile_from_root_dir_when_request_from_non_roo @patch(f'{__app_name__}.common.profile.get_user_profile_path') @patch(f'{__app_name__}.common.profile.get_root_profile_path', side_effect=get_test_root_profile_path) @patch(f'{__app_name__}.service.optimizer.handler.run_tasks', return_value=None) - async def test_handle__must_load_profile_from_root_dir_when_request_from_root_user(self, run_tasks: Mock, get_root_profile_path: Mock, get_user_profile_path: Mock, os_path_exists: Mock, time: Mock): + async def test_handle__must_load_profile_from_root_dir_when_request_from_root_user(self, *mocks: Mock): + run_tasks, get_root_profile_path, get_user_profile_path = mocks[0], mocks[1], mocks[2] + os_path_exists, time = mocks[3], mocks[4] + tasks_man = MagicMock() tasks_man.get_available_environment_tasks = AsyncMock(return_value=None) tasks_man.get_available_process_tasks = AsyncMock(return_value=None) @@ -219,7 +229,10 @@ async def test_handle__must_load_profile_from_root_dir_when_request_from_root_us @patch(f'{__app_name__}.common.profile.get_user_profile_path') @patch(f'{__app_name__}.common.profile.get_root_profile_path', side_effect=get_test_root_profile_path) @patch(f'{__app_name__}.service.optimizer.handler.run_tasks', return_value=None) - async def test_handle__must_read_the_default_profile_when_defined_cannot_be_found(self, run_tasks: Mock, get_root_profile_path: Mock, get_user_profile_path: Mock, os_path_exists: Mock, time: Mock): + async def test_handle__must_read_the_default_profile_when_defined_cannot_be_found(self, *mocks: Mock): + run_tasks, get_root_profile_path, get_user_profile_path = mocks[0], mocks[1], mocks[2] + os_path_exists, time = mocks[3], mocks[4] + tasks_man = MagicMock() tasks_man.get_available_environment_tasks = AsyncMock(return_value=None) tasks_man.get_available_process_tasks = AsyncMock(return_value=None) @@ -253,7 +266,10 @@ async def test_handle__must_read_the_default_profile_when_defined_cannot_be_foun @patch(f'{__app_name__}.common.profile.get_root_profile_path', side_effect=get_test_root_profile_path) @patch(f'{__app_name__}.service.optimizer.handler.get_default_profile_name', return_value="teste123") @patch(f'{__app_name__}.service.optimizer.handler.run_tasks', return_value=None) - async def test_handle__must_do_nothing_when_defined_or_default_profiles_cannot_be_found(self, run_tasks: Mock, get_default_profile_name: Mock, get_root_profile_path: Mock, get_user_profile_path: Mock, os_path_exists: Mock): + async def test_handle__must_do_nothing_when_defined_or_default_profiles_cannot_be_found(self, *mocks: Mock): + run_tasks, get_default_profile_name, get_root_profile_path = mocks[0], mocks[1], mocks[2] + get_user_profile_path, os_path_exists = mocks[3], mocks[4] + tasks_man = MagicMock() tasks_man.get_available_environment_tasks = AsyncMock(return_value=None) tasks_man.get_available_process_tasks = AsyncMock(return_value=None) @@ -297,12 +313,14 @@ async def test_handle__must_remove_the_request_pid_from_the_processing_queue_whe @patch('os.path.exists', return_value=True) @patch(f'{__app_name__}.service.optimizer.handler.run_tasks', return_value=None) @patch(f'{__app_name__}.service.optimizer.handler.time.time', return_value=123456789) - async def test_handle__must_remove_the_request_pid_after_the_optimizations(self, time_mock: Mock, run_tasks: Mock, os_path_exists: Mock): + async def test_handle__must_remove_the_request_pid_after_the_optimizations(self, *mocks: Mock): + time_mock, run_tasks, os_path_exists = mocks[0], mocks[1], mocks[2] + tasks_man = MagicMock() tasks_man.get_available_environment_tasks = AsyncMock(return_value=None) proc_task = Mock(run=AsyncMock()) - tasks_man.get_available_process_tasks = AsyncMock(return_value=[proc_task]) + tasks_man.get_available_process_tasks = AsyncMock(return_value=(proc_task,)) watcher_man = MagicMock() watcher_man.watch = AsyncMock() @@ -311,8 +329,10 @@ async def test_handle__must_remove_the_request_pid_after_the_optimizations(self, self.assertIn(self.request.pid, self.context.queue.get_view()) - handler = OptimizationHandler(context=self.context, tasks_man=tasks_man, watcher_man=watcher_man, profile_reader=self.reader) - handler._launcher_mapper = Mock(map_pid=AsyncMock(return_value=4788)) # mapped pid + handler = OptimizationHandler(context=self.context, tasks_man=tasks_man, + watcher_man=watcher_man, profile_reader=self.reader) + + handler._launcher_mapper = Mock(map_pids=Mock(return_value=AsyncIterator([4788]))) await handler.handle(self.request) exp_prof = OptimizationProfile.empty() @@ -321,13 +341,16 @@ async def test_handle__must_remove_the_request_pid_after_the_optimizations(self, exp_prof.process.io = None exp_prof.process.scheduling = None - exp_proc = OptimizedProcess(self.request, profile=exp_prof, created_at=123456789) - exp_proc.pid = 4788 + exp_source_proc = OptimizedProcess(self.request, profile=exp_prof, created_at=123456789) os_path_exists.assert_called_once_with(f'/proc/{self.request.pid}') - tasks_man.get_available_environment_tasks.assert_called_once_with(exp_proc) - tasks_man.get_available_process_tasks.assert_called_once_with(exp_proc) - run_tasks.assert_called_once_with([proc_task], exp_proc) + tasks_man.get_available_environment_tasks.assert_called_once_with(exp_source_proc) + tasks_man.get_available_process_tasks.assert_called_once_with(exp_source_proc) + + exp_mapped_proc = exp_source_proc.clone() + exp_mapped_proc.pid = 4788 + + run_tasks.assert_called_once_with((proc_task,), exp_mapped_proc) time_mock.assert_called() watcher_man.watch.assert_not_called() @@ -338,7 +361,9 @@ async def test_handle__must_remove_the_request_pid_after_the_optimizations(self, @patch('os.path.exists', return_value=True) @patch(f'{__app_name__}.service.optimizer.handler.run_tasks', return_value=None) @patch(f'{__app_name__}.service.optimizer.handler.time.time', return_value=123456789) - async def test_handle__must_remove_the_source_pid_and_keep_the_mapped_pid_on_the_processing_queue_when_watched(self, time_mock: Mock, run_tasks: Mock, os_path_exists: Mock): + async def test_handle__must_remove_the_source_pid_and_keep_the_mapped_pid_on_the_processing_queue_when_watched(self,*mocks: Mock): + time_mock, run_tasks, os_path_exists = mocks[0], mocks[1], mocks[2] + tasks_man = MagicMock() tasks_man.get_available_environment_tasks = AsyncMock(return_value=None) @@ -352,8 +377,9 @@ async def test_handle__must_remove_the_source_pid_and_keep_the_mapped_pid_on_the self.request.related_pids = {998877} self.assertIn(self.request.pid, self.context.queue.get_view()) - handler = OptimizationHandler(context=self.context, tasks_man=tasks_man, watcher_man=watcher_man, profile_reader=self.reader) - handler._launcher_mapper = Mock(map_pid=AsyncMock(return_value=4788)) # mapped pid + handler = OptimizationHandler(context=self.context, tasks_man=tasks_man, watcher_man=watcher_man, + profile_reader=self.reader) + handler._launcher_mapper = Mock(map_pids=Mock(return_value=AsyncIterator([4788]))) await handler.handle(self.request) exp_prof = OptimizationProfile.empty() @@ -362,16 +388,282 @@ async def test_handle__must_remove_the_source_pid_and_keep_the_mapped_pid_on_the exp_prof.process.io = None exp_prof.process.scheduling = None - exp_proc = OptimizedProcess(self.request, profile=exp_prof, created_at=123456789) - exp_proc.pid = 4788 + exp_source_proc = OptimizedProcess(self.request, profile=exp_prof, created_at=123456789) os_path_exists.assert_called_once_with(f'/proc/{self.request.pid}') - tasks_man.get_available_environment_tasks.assert_called_once_with(exp_proc) - tasks_man.get_available_process_tasks.assert_called_once_with(exp_proc) - run_tasks.assert_called_once_with([proc_task], exp_proc) + tasks_man.get_available_environment_tasks.assert_called_once_with(exp_source_proc) + tasks_man.get_available_process_tasks.assert_called_once_with(exp_source_proc) + + exp_mapped_proc = exp_source_proc.clone() + exp_mapped_proc.pid = 4788 + + run_tasks.assert_called_once_with([proc_task], exp_mapped_proc) time_mock.assert_called() - watcher_man.watch.assert_awaited_once_with(exp_proc) + watcher_man.watch.assert_awaited_once_with(exp_mapped_proc) self.assertIn(4788, self.context.queue.get_view()) self.assertNotIn(self.request.pid, self.context.queue.get_view()) + + @patch('os.path.exists', return_value=True) + @patch(f'{__app_name__}.service.optimizer.handler.time.time', return_value=123456789) + async def test_handle__must_await_for_env_and_process_tasks(self, *mocks: Mock): + time_mock, os_path_exists = mocks[0], mocks[1] + + tasks_man = MagicMock() + + async def mock_cpu_change(proc): + proc.cpu_energy_policy_changed = True + + env_task = AsyncMock(side_effect=mock_cpu_change) + tasks_man.get_available_environment_tasks = AsyncMock(return_value=[Mock(run=env_task)]) + + proc_task = AsyncMock() + tasks_man.get_available_process_tasks = AsyncMock(return_value=[Mock(run=proc_task)]) + + watcher_man = MagicMock() + watcher_man.watch = AsyncMock() + + self.request.config = "cpu.performance\nproc.nice=-1" + self.assertIn(self.request.pid, self.context.queue.get_view()) + + handler = OptimizationHandler(context=self.context, tasks_man=tasks_man, watcher_man=watcher_man, + profile_reader=self.reader) + handler._launcher_mapper = Mock(map_pids=Mock(return_value=AsyncIterator(tuple()))) + await handler.handle(self.request) + + exp_prof = OptimizationProfile.empty() + exp_prof.process = ProcessSettings(None) + exp_prof.process.nice.level = -1 + exp_prof.process.io = None + exp_prof.process.scheduling = None + exp_prof.cpu = CPUSettings(performance=True) + + exp_source_proc = OptimizedProcess(self.request, profile=exp_prof, created_at=123456789) + exp_source_proc.cpu_energy_policy_changed = True + + os_path_exists.assert_called_once_with(f'/proc/{self.request.pid}') + + # at this moment, 'cpu_energy_policy_changed' would not have been set, but the Mock library + # seems to trace the same instance (not its state) + tasks_man.get_available_environment_tasks.assert_called_once_with(exp_source_proc) + tasks_man.get_available_process_tasks.assert_called_once_with(exp_source_proc) + + env_task.assert_awaited_with(exp_source_proc) + proc_task.assert_called_once_with(exp_source_proc) + time_mock.assert_called() + + watcher_man.watch.assert_awaited_once_with(exp_source_proc) + self.assertIn(exp_source_proc.pid, self.context.queue.get_view()) + + @patch('os.path.exists', return_value=True) + @patch(f'{__app_name__}.service.optimizer.handler.time.time', return_value=123456789) + async def test_handle__must_await_for_env_and_process_tasks_when_the_source_process_is_mapped(self, *mocks: Mock): + time_mock, os_path_exists = mocks[0], mocks[1] + + tasks_man = MagicMock() + + async def mock_cpu_change(proc): + proc.cpu_energy_policy_changed = True + + env_task = AsyncMock(side_effect=mock_cpu_change) + tasks_man.get_available_environment_tasks = AsyncMock(return_value=[Mock(run=env_task)]) + + proc_task = AsyncMock() + tasks_man.get_available_process_tasks = AsyncMock(return_value=[Mock(run=proc_task)]) + + watcher_man = MagicMock() + watcher_man.watch = AsyncMock() + + self.request.config = "cpu.performance\nproc.nice=-1" + self.assertIn(self.request.pid, self.context.queue.get_view()) + + handler = OptimizationHandler(context=self.context, tasks_man=tasks_man, watcher_man=watcher_man, + profile_reader=self.reader) + handler._launcher_mapper = Mock(map_pids=Mock(return_value=AsyncIterator([4788]))) + await handler.handle(self.request) + + exp_prof = OptimizationProfile.empty() + exp_prof.process = ProcessSettings(None) + exp_prof.process.nice.level = -1 + exp_prof.process.io = None + exp_prof.process.scheduling = None + exp_prof.cpu = CPUSettings(performance=True) + + exp_source_proc = OptimizedProcess(self.request, profile=exp_prof, created_at=123456789) + + os_path_exists.assert_called_once_with(f'/proc/{self.request.pid}') + + tasks_man.get_available_process_tasks.assert_called_once_with(exp_source_proc) + tasks_man.get_available_environment_tasks.assert_called_once_with(exp_source_proc) + + exp_cloned_proc = exp_source_proc.clone() + exp_cloned_proc.pid = 4788 + exp_cloned_proc.cpu_energy_policy_changed = True + + # tasks should only be executed over the mapped process + env_task.assert_awaited_with(exp_cloned_proc) + proc_task.assert_called_once_with(exp_cloned_proc) + time_mock.assert_called() + + watcher_man.watch.assert_awaited_once_with(exp_cloned_proc) + self.assertIn(exp_cloned_proc.pid, self.context.queue.get_view()) + self.assertNotIn(exp_source_proc.pid, self.context.queue.get_view()) + + @patch('os.path.exists', return_value=True) + @patch(f'{__app_name__}.service.optimizer.handler.time.time', return_value=123456789) + async def test_handle__must_execute_env_tasks_for_every_mapped_processes(self, *mocks: Mock): + time_mock, os_path_exists = mocks[0], mocks[1] + + tasks_man = MagicMock() + + async def mock_cpu_change(proc): + proc.cpu_energy_policy_changed = True + + env_task = AsyncMock(side_effect=mock_cpu_change) + tasks_man.get_available_environment_tasks = AsyncMock(return_value=[Mock(run=env_task)]) + + proc_task = AsyncMock() + tasks_man.get_available_process_tasks = AsyncMock(return_value=[Mock(run=proc_task)]) + + watcher_man = MagicMock() + watcher_man.watch = AsyncMock() + + self.request.config = "cpu.performance\nproc.nice=-1" + self.assertIn(self.request.pid, self.context.queue.get_view()) + + handler = OptimizationHandler(context=self.context, tasks_man=tasks_man, watcher_man=watcher_man, + profile_reader=self.reader) + handler._launcher_mapper = Mock(map_pids=Mock(return_value=AsyncIterator([4788, 4789]))) + await handler.handle(self.request) + + exp_prof = OptimizationProfile.empty() + exp_prof.process = ProcessSettings(None) + exp_prof.process.nice.level = -1 + exp_prof.process.io = None + exp_prof.process.scheduling = None + exp_prof.cpu = CPUSettings(performance=True) + + exp_source_proc = OptimizedProcess(self.request, profile=exp_prof, created_at=123456789) + + os_path_exists.assert_called_once_with(f'/proc/{self.request.pid}') + + tasks_man.get_available_process_tasks.assert_called_once_with(exp_source_proc) + tasks_man.get_available_environment_tasks.assert_called_once_with(exp_source_proc) + + exp_cloned_proc_1 = exp_source_proc.clone() + exp_cloned_proc_1.pid = 4788 + exp_cloned_proc_1.cpu_energy_policy_changed = True + + exp_cloned_proc_2 = exp_source_proc.clone() + exp_cloned_proc_2.pid = 4789 + exp_cloned_proc_2.cpu_energy_policy_changed = True + + time_mock.assert_called() + env_task.assert_has_calls([call(exp_cloned_proc_1), call(exp_cloned_proc_2)]) + proc_task.assert_has_calls([call(exp_cloned_proc_1), call(exp_cloned_proc_2)]) + watcher_man.watch.assert_has_calls([call(exp_cloned_proc_1), call(exp_cloned_proc_2)]) + + for clone in (exp_cloned_proc_1, exp_cloned_proc_2): + self.assertIn(clone.pid, self.context.queue.get_view()) + + self.assertNotIn(exp_source_proc.pid, self.context.queue.get_view()) + + @patch('os.path.exists', return_value=True) + @patch(f'{__app_name__}.service.optimizer.handler.time.time', return_value=123456789) + async def test_handle__must_execute_env_tasks_for_source_process_when_no_mapping_or_proc_tasks(self, *mocks: Mock): + time_mock, os_path_exists = mocks[0], mocks[1] + + tasks_man = MagicMock() + + async def mock_cpu_change(proc): + proc.cpu_energy_policy_changed = True + + env_task = AsyncMock(side_effect=mock_cpu_change) + tasks_man.get_available_environment_tasks = AsyncMock(return_value=[Mock(run=env_task)]) + + tasks_man.get_available_process_tasks = AsyncMock(return_value=[]) + + watcher_man = MagicMock() + watcher_man.watch = AsyncMock() + + self.request.config = "cpu.performance\nproc.nice=-1" + self.assertIn(self.request.pid, self.context.queue.get_view()) + + handler = OptimizationHandler(context=self.context, tasks_man=tasks_man, watcher_man=watcher_man, + profile_reader=self.reader) + handler._launcher_mapper = Mock(map_pids=Mock(return_value=AsyncIterator([]))) + await handler.handle(self.request) + + exp_prof = OptimizationProfile.empty() + exp_prof.process = ProcessSettings(None) + exp_prof.process.nice.level = -1 + exp_prof.process.io = None + exp_prof.process.scheduling = None + exp_prof.cpu = CPUSettings(performance=True) + + exp_source_proc = OptimizedProcess(self.request, profile=exp_prof, created_at=123456789) + exp_source_proc.cpu_energy_policy_changed = True + + os_path_exists.assert_called_once_with(f'/proc/{self.request.pid}') + + tasks_man.get_available_process_tasks.assert_called_once_with(exp_source_proc) + tasks_man.get_available_environment_tasks.assert_called_once_with(exp_source_proc) + + time_mock.assert_called() + env_task.assert_awaited_once_with(exp_source_proc) + watcher_man.watch.assert_awaited_once_with(exp_source_proc) + + self.assertIn(exp_source_proc.pid, self.context.queue.get_view()) + + @patch('os.path.exists', return_value=True) + @patch(f'{__app_name__}.service.optimizer.handler.time.time', return_value=123456789) + async def test_handle__must_execute_env_and_proc_tasks_for_source_process_when_no_mapping(self, *mocks: Mock): + time_mock, os_path_exists = mocks[0], mocks[1] + + tasks_man = MagicMock() + + async def mock_cpu_change(proc): + proc.cpu_energy_policy_changed = True + + env_task = AsyncMock(side_effect=mock_cpu_change) + tasks_man.get_available_environment_tasks = AsyncMock(return_value=[Mock(run=env_task)]) + + proc_task = AsyncMock() + tasks_man.get_available_process_tasks = AsyncMock(return_value=[Mock(run=proc_task)]) + + watcher_man = MagicMock() + watcher_man.watch = AsyncMock() + + self.request.config = "cpu.performance\nproc.nice=-1" + self.assertIn(self.request.pid, self.context.queue.get_view()) + + handler = OptimizationHandler(context=self.context, tasks_man=tasks_man, watcher_man=watcher_man, + profile_reader=self.reader) + handler._launcher_mapper = Mock(map_pids=Mock(return_value=AsyncIterator([]))) + await handler.handle(self.request) + + exp_prof = OptimizationProfile.empty() + exp_prof.process = ProcessSettings(None) + exp_prof.process.nice.level = -1 + exp_prof.process.io = None + exp_prof.process.scheduling = None + exp_prof.cpu = CPUSettings(performance=True) + + exp_source_proc = OptimizedProcess(self.request, profile=exp_prof, created_at=123456789) + exp_source_proc.cpu_energy_policy_changed = True + + os_path_exists.assert_called_once_with(f'/proc/{self.request.pid}') + + tasks_man.get_available_process_tasks.assert_called_once_with(exp_source_proc) + tasks_man.get_available_environment_tasks.assert_called_once_with(exp_source_proc) + + time_mock.assert_called() + + env_task.assert_awaited_once_with(exp_source_proc) + exp_source_proc.cpu_energy_policy_changed = True + + proc_task.assert_awaited_with(exp_source_proc) + + watcher_man.watch.assert_awaited_once_with(exp_source_proc) + self.assertIn(exp_source_proc.pid, self.context.queue.get_view()) diff --git a/tests/service/optimizer/test_launcher.py b/tests/service/optimizer/test_launcher.py index c5e2c51..cb22c13 100644 --- a/tests/service/optimizer/test_launcher.py +++ b/tests/service/optimizer/test_launcher.py @@ -1,16 +1,13 @@ -import re -from asyncio import Future +from typing import Set, Optional from unittest import IsolatedAsyncioTestCase, TestCase -from unittest.mock import Mock, patch, AsyncMock, MagicMock, call +from unittest.mock import Mock, patch, AsyncMock, call from guapow import __app_name__ -from guapow.common import util from guapow.common.dto import OptimizationRequest -from guapow.common.steam import get_proton_exec_name_and_paths, get_steam_runtime_command from guapow.service.optimizer.launcher import map_launchers_file, gen_possible_launchers_file_paths, \ LauncherSearchMode, map_launchers_dict, ExplicitLauncherMapper, SteamLauncherMapper, LauncherMapperManager from guapow.service.optimizer.profile import OptimizationProfile, LauncherSettings -from tests import RESOURCES_DIR +from tests import RESOURCES_DIR, AsyncIterator, MockedAsyncCall def new_steam_profile(enabled: bool) -> OptimizationProfile: @@ -95,235 +92,873 @@ class ExplicitLauncherMapperTest(IsolatedAsyncioTestCase): def setUp(self): self.logger = Mock() - self.mapper = ExplicitLauncherMapper(wait_time=30, logger=self.logger) + self.mapper = ExplicitLauncherMapper(check_time=0.1, found_check_time=0, logger=self.logger) @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file', side_effect=FileNotFoundError) - async def test_map_pid__return_none_when_there_is_no_possible_launchers_file_available(self, map_launchers: Mock): + async def test_map_pids__it_should_not_yield_when_no_launcher_files_available(self, map_launchers: Mock): request = OptimizationRequest(pid=123, command='/usr/local/bin/game', user_name='user') profile = new_steam_profile(enabled=False) - returned_pid = await self.mapper.map_pid(request, profile) - self.assertIsNone(returned_pid) + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + self.assertFalse(await map_pids()) exp_file_paths = gen_possible_launchers_file_paths(user_id=123, user_name='user') map_launchers.assert_has_calls([call(fpath, self.logger) for fpath in exp_file_paths]) - @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file', return_value={'game': ('game_x86_64.bin', LauncherSearchMode.NAME)}) - @patch(f'{__app_name__}.common.system.find_process_by_name', return_value=(456, 'game_x86_64.bin')) - async def test_map_pid__return_pid_for_matched_launcher_mapped_name(self, find_process_by_name: Mock, map_launchers: Mock): + @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file') + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_yield_pid_for_only_one_name_match(self, *mocks: AsyncMock): + async_syscall, map_launchers = mocks[0], mocks[1] + async_syscall.return_value = (0, "456# game_x86_64.bin\n789# other") + map_launchers.return_value = {"game": ("game_x86_64.bin", LauncherSearchMode.NAME)} + request = OptimizationRequest(pid=123, command='/usr/local/bin/game', user_name='user') profile = new_steam_profile(enabled=False) - returned_pid = await self.mapper.map_pid(request, profile) - self.assertEqual(456, returned_pid) + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() exp_file_paths = [*gen_possible_launchers_file_paths(user_id=123, user_name='user')] - map_launchers.assert_called_once_with(exp_file_paths[0], self.logger) - find_process_by_name.assert_called_once_with(util.map_any_regex('game_x86_64.bin'), last_match=True) + map_launchers.assert_awaited_once_with(exp_file_paths[0], self.logger) + async_syscall.assert_awaited_once_with('ps -Ao "%p#%c" -ww --no-headers') + + self.assertEqual({456}, mapped_pids) + + @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file') + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_yield_pids_for_several_name_matches(self, *mocks: AsyncMock): + async_syscall, map_launchers = mocks[0], mocks[1] + async_syscall.return_value = (0, "456# game_x86_64.bin\n789# other\n1011# game_x86_64.bin") + map_launchers.return_value = {"game": ("game_x86_64.bin", LauncherSearchMode.NAME)} - @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file', side_effect=[FileNotFoundError, {'game': ('game_x86_64.bin', LauncherSearchMode.NAME)}]) - @patch(f'{__app_name__}.common.system.find_process_by_name', return_value=(456, 'game_x86_64.bin')) - async def test_map_pid__return_pid_for_matched_etc_launchers_file_when_user_file_does_not_exist(self, find_process_by_name: Mock, map_launchers: Mock): request = OptimizationRequest(pid=123, command='/usr/local/bin/game', user_name='user') profile = new_steam_profile(enabled=False) - returned_pid = await self.mapper.map_pid(request, profile) - self.assertEqual(456, returned_pid) + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + + exp_file_paths = [*gen_possible_launchers_file_paths(user_id=123, user_name='user')] + map_launchers.assert_awaited_once_with(exp_file_paths[0], self.logger) + async_syscall.assert_awaited_once_with('ps -Ao "%p#%c" -ww --no-headers') + + self.assertEqual({456, 1011}, mapped_pids) + + @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file') + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_yield_pid_for_matched_etc_launchers_file_when_no_user_file(self, *mocks: Mock): + async_syscall, map_launchers = mocks[0], mocks[1] + + map_launchers.side_effect = [FileNotFoundError, {'game': ('game_x86_64.bin', LauncherSearchMode.NAME)}] + async_syscall.return_value = (0, "456# game_x86_64.bin\n789# other\n") + + request = OptimizationRequest(pid=123, command='/usr/local/bin/game', user_name='user') + profile = new_steam_profile(enabled=False) + + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() exp_file_paths = [*gen_possible_launchers_file_paths(user_id=123, user_name='user')] map_launchers.assert_has_calls([call(fpath, self.logger) for fpath in exp_file_paths]) - find_process_by_name.assert_called_once_with(util.map_any_regex('game_x86_64.bin'), last_match=True) + async_syscall.assert_awaited_with('ps -Ao "%p#%c" -ww --no-headers') + + self.assertEqual({456}, mapped_pids) + + @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file') + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_yield_pid_for_matched_etc_launchers_file_for_root_user_call(self, *mocks: Mock): + async_syscall, map_launchers = mocks[0], mocks[1] + map_launchers.return_value = {'game': ('game_x86_64.bin', LauncherSearchMode.NAME)} + async_syscall.return_value = (0, "456# game_x86_64.bin\n789# other\n") - @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file', return_value={'game': ('game_x86_64.bin', LauncherSearchMode.NAME)}) - @patch(f'{__app_name__}.common.system.find_process_by_name', return_value=(456, 'game_x86_64.bin')) - async def test_map_pid__return_pid_for_matched_etc_launchers_file_for_root_call(self, find_process_by_name: Mock, map_launchers: Mock): request = OptimizationRequest(pid=123, command='/usr/local/bin/game', user_name='root') request.user_id = 0 profile = new_steam_profile(enabled=False) - returned_pid = await self.mapper.map_pid(request, profile) - self.assertEqual(456, returned_pid) + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} - map_launchers.assert_called_once_with(f'/etc/{__app_name__}/launchers', self.logger) - find_process_by_name.assert_called_once_with(util.map_any_regex('game_x86_64.bin'), last_match=True) + mapped_pids = await map_pids() + + map_launchers.assert_awaited_once_with(f'/etc/{__app_name__}/launchers', self.logger) + async_syscall.assert_awaited_with('ps -Ao "%p#%c" -ww --no-headers') + + self.assertEqual(mapped_pids, await map_pids()) + + @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file') + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_yield_pid_for_only_one_matched_launcher_mapped_cmd(self, *mocks: AsyncMock): + async_syscall, map_launchers = mocks[0], mocks[1] + + map_launchers.return_value = {'game': ('/path/to/game_x86_64.bin', LauncherSearchMode.COMMAND)} + async_syscall.return_value = (0, "456# /path/to/game_x86_64.bin\n789# other\n") - @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file', return_value={'game': ('/path/to/game_x86_64.bin', LauncherSearchMode.COMMAND)}) - @patch(f'{__app_name__}.common.system.find_process_by_command', return_value=(456, '/path/to/game_x86_64.bin')) - async def test_map_pid__return_pid_for_matched_launcher_mapped_cmd(self, find_process_by_command: Mock, map_launchers: Mock): request = OptimizationRequest(pid=123, command='/usr/local/bin/game', user_name='user') profile = new_steam_profile(enabled=False) - returned_pid = await self.mapper.map_pid(request, profile) - self.assertEqual(456, returned_pid) - map_launchers.assert_called_once() - find_process_by_command.assert_called_once_with({util.map_any_regex('/path/to/game_x86_64.bin')}, last_match=True) + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + map_launchers.assert_awaited_once() + async_syscall.assert_awaited_once_with('ps -Ao "%p#%a" -ww --no-headers') + + self.assertEqual({456}, mapped_pids) + + @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file') + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_yield_pid_for_mapped_name_regex(self, *mocks: AsyncMock): + async_syscall, map_launchers = mocks[0], mocks[1] + + map_launchers.return_value = {'game': (' *game_x86_64.bin ', LauncherSearchMode.NAME)} + async_syscall.return_value = (0, "456# /game_x86_64.bin\n789# other\n") - @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file', return_value={'game': (' *game_x86_64.bin ', LauncherSearchMode.NAME)}) - @patch(f'{__app_name__}.common.system.find_process_by_name', return_value=(456, '/game_x86_64.bin')) - async def test_map_pid__return_pid_for_matched_launcher_mapped_name_regex(self, find_process_by_name: Mock, map_launchers: Mock): request = OptimizationRequest(pid=123, command='/usr/local/bin/game', user_name='user') profile = new_steam_profile(enabled=False) - returned_pid = await self.mapper.map_pid(request, profile) - self.assertEqual(456, returned_pid) + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() exp_file_paths = [*gen_possible_launchers_file_paths(user_id=123, user_name='user')] - map_launchers.assert_called_once_with(exp_file_paths[0], self.logger) - find_process_by_name.assert_called_once_with(util.map_any_regex(' *game_x86_64.bin '), last_match=True) + map_launchers.assert_awaited_once_with(exp_file_paths[0], self.logger) + async_syscall.assert_awaited_once_with('ps -Ao "%p#%c" -ww --no-headers') + + self.assertEqual({456}, mapped_pids) + + @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file') + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_yield_pid_for_mapped_cmd_regex(self, *mocks: Mock): + async_syscall, map_launchers = mocks[0], mocks[1] + + map_launchers.return_value = {'game': ('/*game_x86_64.bin ', LauncherSearchMode.COMMAND)} + async_syscall.return_value = (0, "456# /game_x86_64.bin\n789# other\n") - @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file', return_value={'game': ('/*game_x86_64.bin ', LauncherSearchMode.COMMAND)}) - @patch(f'{__app_name__}.common.system.find_process_by_command', return_value=(456, '/game_x86_64.bin')) - async def test_map_pid__return_pid_for_matched_launcher_mapped_cmd_regex(self, find_process_by_command: Mock, map_launchers: Mock): request = OptimizationRequest(pid=123, command='/usr/local/bin/game', user_name='user') profile = new_steam_profile(enabled=False) - returned_pid = await self.mapper.map_pid(request, profile) - self.assertEqual(456, returned_pid) - map_launchers.assert_called_once() - find_process_by_command.assert_called_once_with({util.map_any_regex('/*game_x86_64.bin ')}, last_match=True) + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + + map_launchers.assert_awaited_once() + async_syscall.assert_awaited_once_with('ps -Ao "%p#%a" -ww --no-headers') + + self.assertEqual({456}, mapped_pids) + + @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file') + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_yield_pid_for_matched_launcher_using_a_wild_card(self, *mocks: Mock): + async_syscall, map_launchers = mocks[0], mocks[1] + + map_launchers.return_value = {'hl2.sh*': ('hl2.linux', LauncherSearchMode.NAME)} + async_syscall.return_value = (0, "456# hl2.linux\n789# other\n") - @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file', return_value={'hl2.sh*': ('hl2.linux', LauncherSearchMode.NAME)}) - @patch(f'{__app_name__}.common.system.find_process_by_name', return_value=(456, 'hl2.linux')) - async def test_map_pid__return_pid_for_matched_launcher_using_a_wild_card(self, find_process_by_name: Mock, map_launchers: Mock): cmd = '/home/user/.local/share/Steam/steamapps/common/Team Fortress 2/hl2.sh -game tf -steam' request = OptimizationRequest(pid=123, command=cmd, user_name='user') profile = new_steam_profile(enabled=False) - returned_pid = await self.mapper.map_pid(request, profile) - self.assertEqual(456, returned_pid) - map_launchers.assert_called_once() - find_process_by_name.assert_called_once_with(util.map_any_regex('hl2.linux'), last_match=True) + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + + map_launchers.assert_awaited_once() + async_syscall.assert_awaited_once_with('ps -Ao "%p#%c" -ww --no-headers') + + self.assertEqual({456}, mapped_pids) + + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_yield_pid_for_matched_launcher_via_profile(self, async_syscall: AsyncMock): + async_syscall.return_value = (0, "456# /bin/proc_abc\n789# other\n") - @patch(f'{__app_name__}.common.system.find_process_by_command', return_value=(456, '/bin/proc_abc')) - async def test_map_pid__return_pid_for_matched_launcher_defined_via_profile(self, find_process_by_command: Mock): request = OptimizationRequest(pid=123, command='/home/user/.local/bin/proc1', user_name='user') profile = OptimizationProfile.empty('test') profile.launcher = LauncherSettings({'proc1': 'c%/bin/proc_abc'}, None) - returned_pid = await self.mapper.map_pid(request, profile) - self.assertEqual(456, returned_pid) - find_process_by_command.assert_called_once_with({util.map_any_regex('/bin/proc_abc')}, last_match=True) + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + + async_syscall.assert_awaited_once_with('ps -Ao "%p#%a" -ww --no-headers') + self.assertEqual({456}, mapped_pids) + + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_yield_pid_for_matched_launcher_via_profile_using_wildcards(self, *mocks: Mock): + async_syscall = mocks[0] + async_syscall.return_value = (0, "456# /bin/proc_abc\n789# other\n") - @patch(f'{__app_name__}.common.system.find_process_by_command', return_value=(456, '/bin/proc_abc')) - async def test_map_pid__return_pid_for_matched_launcher_defined_via_profile_using_wildcards(self, find_process_by_command: Mock): request = OptimizationRequest(pid=123, command='/home/user/.local/bin/proc1', user_name='user') profile = OptimizationProfile.empty('test') profile.launcher = LauncherSettings({'proc1': 'c%/bin/proc_*'}, False) - returned_pid = await self.mapper.map_pid(request, profile) - self.assertEqual(456, returned_pid) - find_process_by_command.assert_called_once_with({util.map_any_regex('/bin/proc_*')}, last_match=True) + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + + async_syscall.assert_awaited_once_with('ps -Ao "%p#%a" -ww --no-headers') + self.assertEqual({456}, mapped_pids) + + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_not_yield_when_defined_launchers_via_profile_are_invalid(self, *mocks: Mock): + async_syscall = mocks[0] - @patch(f'{__app_name__}.common.system.find_process_by_command', return_value=None) - @patch(f'{__app_name__}.common.system.find_process_by_name', return_value=None) - async def test_map_pid__return_none_for_when_defined_launchers_via_profile_are_invalid(self, find_process_by_command: Mock, find_latest_process_by_cmd: Mock): request = OptimizationRequest(pid=123, command='/home/user/.local/bin/proc1', user_name='user') profile = OptimizationProfile.empty('test') profile.launcher = LauncherSettings({'proc1': ''}, None) - returned_pid = await self.mapper.map_pid(request, profile) - self.assertIsNone(returned_pid) - find_latest_process_by_cmd.assert_not_called() - find_process_by_command.assert_not_called() + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() - @patch(f'{__app_name__}.common.system.find_process_by_command', return_value=None) - @patch(f'{__app_name__}.common.system.find_process_by_name', return_value=None) - async def test_map_pid__return_none_when_skip_mapping_is_true(self, find_process_by_name: Mock, find_process_by_command: Mock): + async_syscall.assert_not_awaited() + self.assertEqual(set(), mapped_pids) + + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_not_yield_when_skip_mapping_is_true(self, async_syscall: AsyncMock): request = OptimizationRequest(pid=123, command='/home/user/.local/bin/proc1', user_name='user') profile = OptimizationProfile.empty('test') profile.launcher = LauncherSettings({'proc1': '/bin/proc1'}, True) - returned_pid = await self.mapper.map_pid(request, profile) - self.assertIsNone(returned_pid) - find_process_by_command.assert_not_called() - find_process_by_name.assert_not_called() + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + + async_syscall.assert_not_awaited() + self.assertEqual(set(), mapped_pids) + + @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file') + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_yield_pid_for_several_matches_while_not_timed_out(self, *mocks: AsyncMock): + async_syscall, map_launchers = mocks[0], mocks[1] + + mocked_call = MockedAsyncCall(results=[(0, "456# game_x86_64.bin\n789# other\n"), + (0, "456# game_x86_64.bin\n789# other\n1011# game_x86_64.bin")], + await_time=0.0005) + async_syscall.side_effect = mocked_call.call + + map_launchers.return_value = {"game": ("game_x86_64.bin", LauncherSearchMode.NAME)} + + request = OptimizationRequest(pid=123, command='/usr/local/bin/game', user_name='user') + profile = new_steam_profile(enabled=False) + + self.mapper = ExplicitLauncherMapper(check_time=0.01, found_check_time=-1, iteration_sleep_time=0, + logger=self.logger) + + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + + exp_file_paths = [*gen_possible_launchers_file_paths(user_id=123, user_name='user')] + map_launchers.assert_awaited_once_with(exp_file_paths[0], self.logger) + async_syscall.assert_awaited_with('ps -Ao "%p#%c" -ww --no-headers') + self.assertGreaterEqual(async_syscall.await_count, 2) + + self.assertEqual({456, 1011}, mapped_pids) + + @patch(f'{__app_name__}.service.optimizer.launcher.map_launchers_file') + @patch(f'{__app_name__}.service.optimizer.launcher.async_syscall') + async def test_map_pids__it_should_stop_yielding_when_found_timeout_is_reached(self, *mocks: AsyncMock): + async_syscall, map_launchers = mocks[0], mocks[1] + + async_syscall.side_effect = [(0, "456# game_x86_64.bin\n789# other\n"), + (0, "456# game_x86_64.bin\n789# other\n1011# game_x86_64.bin")] + + map_launchers.return_value = {"game": ("game_x86_64.bin", LauncherSearchMode.NAME)} + + request = OptimizationRequest(pid=123, command='/usr/local/bin/game', user_name='user') + profile = new_steam_profile(enabled=False) + + # setting 'found_check_time' to zero, so the next iteration wouldn't happen + self.mapper = ExplicitLauncherMapper(check_time=0.1, found_check_time=0, iteration_sleep_time=0, + logger=self.logger) + + async def map_pids() -> Set[int]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + + exp_file_paths = [*gen_possible_launchers_file_paths(user_id=123, user_name='user')] + map_launchers.assert_awaited_once_with(exp_file_paths[0], self.logger) + async_syscall.assert_awaited_with('ps -Ao "%p#%c" -ww --no-headers') + self.assertEqual(1, async_syscall.await_count) + + self.assertEqual({456}, mapped_pids) class SteamLauncherMapperTest(IsolatedAsyncioTestCase): def setUp(self): - self.mapper = SteamLauncherMapper(wait_time=0.001, logger=Mock()) + self.mapper = SteamLauncherMapper(check_time=0.0001, found_check_time=0.0001, iteration_sleep_time=0, + logger=Mock()) + + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall", return_value=(0, """ + 1403# 2601# reaper + 2601# 2602# ABC.x86_ + 2601# 2603# ABC.x86_-thread + """)) + async def test_map_pids__yield_several_ids_when_native_command_not_from_runtime(self, async_syscall: AsyncMock): + cmd = "/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=999999 -- " \ + "/home/user/.local/share/Steam/ubuntu12_32/steam-launch-wrapper -- " \ + "/media/hd0/Steam/steamapps/common/Game/ABC.x86_" + + request = OptimizationRequest(pid=2601, command=cmd, user_name='user') + profile = new_steam_profile(enabled=True) - @patch(f'{__app_name__}.common.system.find_process_by_command', return_value=(456, 'Game_x64.exe')) - async def test_map_pid__return_id_when_proton_command_not_from_runtime(self, find_process_by_command: Mock): - cmd = 'home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- /home/user/.local/share/Steam/steamapps/common/Proton 3.16/proton waitforexitandrun /home/user/.local/share/Steam/steamapps/common/Game II/Game_x64.exe' + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertGreaterEqual(async_syscall.await_count, 1) + self.assertEqual({2602, 2603}, mapped_pids) + + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall", return_value=(0, """ + 1403# 11573# reaper + 11573# 11574# pv-bwrap + 11574# 11728# pressure-vessel + 11728# 13786# ABC.x86_ + 11728# 13787# ABC.x86_-thread + """)) + async def test_map_pids__yield_several_ids_when_native_command_from_runtime(self, async_syscall: AsyncMock): + cmd = "/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=245170 -- " \ + "/home/user/.local/share/Steam/ubuntu12_32/steam-launch-wrapper -- " \ + "/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point " \ + "--verb=waitforexitandrun -- " \ + "/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime/scout-on-soldier-entry-point-v2 -- " \ + "/media/hd0/Steam/steamapps/common/Game/ABC.x86_" + + request = OptimizationRequest(pid=11573, command=cmd, user_name='user') + profile = new_steam_profile(enabled=True) + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertGreaterEqual(async_syscall.await_count, 1) + self.assertEqual({13786, 13787}, mapped_pids) + + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall", return_value=(0, """ + 1435# 5614# reaper + 5614# 5615# python3 + 5615# 5661# Game_x64.exe + 5615# 5662# Game_x64-thread + """)) + async def test_map_pids__yield_several_ids_when_proton_command_not_from_runtime(self, async_syscall: AsyncMock): + cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- ' \ + '/home/user/.local/share/Steam/steamapps/common/Proton 3.16/proton waitforexitandrun ' \ + '/home/user/.local/share/Steam/steamapps/common/Game II/Game_x64.exe' + + request = OptimizationRequest(pid=5614, command=cmd, user_name='user') + profile = new_steam_profile(enabled=True) + + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertGreaterEqual(async_syscall.await_count, 1) + self.assertEqual({5661, 5662}, mapped_pids) + + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall") + async def test_map_pids__yield_several_ids_when_proton_command_from_runtime(self, async_syscall: AsyncMock): + mocked_call = MockedAsyncCall(results=[(0, """12# 123# reaper + 123# 456# pv-bwrap + 456# 789# pressure-vessel + 789# 1011# python3 + 789# 1213# Game_x64.exe"""), + (0, """12# 123# reaper + 123# 456# pv-bwrap + 456# 789# pressure-vessel + 789# 1011# python3 + 789# 1213# Game_x64.exe + 789# 1214# Game_x64-thread""") # one more child found + ]) + async_syscall.side_effect = mocked_call.call + + cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- ' \ + '/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point ' \ + '--verb=waitforexitandrun -- ' \ + '/home/user/.local/share/Steam/steamapps/common/Proton 6.3/proton waitforexitandrun ' \ + '/home/user/.local/share/Steam/steamapps/common/Game II/Game_x64.exe' + + self.mapper = SteamLauncherMapper(check_time=0.5, # using a higher wait time for this test case + found_check_time=-1, + logger=Mock()) request = OptimizationRequest(pid=123, command=cmd, user_name='user') profile = new_steam_profile(enabled=True) - returned_pid = await self.mapper.map_pid(request=request, profile=profile) - self.assertEqual(456, returned_pid) + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertGreaterEqual(async_syscall.await_count, 2) + self.assertEqual({1213, 1214}, mapped_pids) + + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall", return_value=(0, """ + 1435# 5614# reaper + 5614# 30324# pv-bwrap + 30324# 30408# pressure-vessel + 30408# 5615# python3 + 30408# 5676# wineserver + 30408# 5711# services.exe + 30408# 5745# winedevice.exe + 30408# 5760# plugplay.exe + 30408# 5765# winedevice.exe + 30408# 5774# explorer.exe + 30408# 5775# OriginWebHelper + 30408# 5776# Origin.exe + 30408# 5777# OriginClientSer + 30408# 5778# QtWebEngineProc + 30408# 5779# EASteamProxy.ex + 30408# 5780# PnkBstrA.exe + 30408# 5781# UPlayBrowser.exe + 30408# 5782# wine + 30408# 5783# wine64 + 30408# 5784# proton + 30408# 5785# gzip + 30408# 5786# steam.exe + 30408# 5787# python + 30408# 5661# Game_x64.exe + """)) + async def test_map_pids__should_not_yield_ignored_processes(self, async_syscall: AsyncMock): + cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- ' \ + '/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point ' \ + '--verb=waitforexitandrun -- ' \ + '/home/user/.local/share/Steam/steamapps/common/Proton 6.3/proton waitforexitandrun ' \ + '/home/user/.local/share/Steam/steamapps/common/Game II/Game_x64.exe' + + request = OptimizationRequest(pid=5614, command=cmd, user_name='user') + profile = new_steam_profile(enabled=True) + + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertGreaterEqual(async_syscall.await_count, 1) + self.assertEqual({5661}, mapped_pids) + + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall", return_value=(0, """ + 1435# 5614# reaper + 5614# 30324# pv-bwrap + 30324# 30408# pressure-vessel + 30408# 5615# python3 + 30408# 5676# wineserver + 30408# 5661# Game_x64.exe + 5661# 5662# wine64 + 5662# 5663# wineboot.exe + """)) + async def test_map_pids__should_not_yield_ignored_that_are_children_of_targets(self, async_syscall: AsyncMock): + cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- ' \ + '/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point ' \ + '--verb=waitforexitandrun -- ' \ + '/home/user/.local/share/Steam/steamapps/common/Proton 6.3/proton waitforexitandrun ' \ + '/home/user/.local/share/Steam/steamapps/common/Game II/Game_x64.exe' + + request = OptimizationRequest(pid=5614, command=cmd, user_name='user') + profile = new_steam_profile(enabled=True) - expected_wine_path = {util.map_any_regex(c) for c in get_proton_exec_name_and_paths(cmd)[1:]} - find_process_by_command.assert_called_once_with(expected_wine_path, last_match=True) + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertGreaterEqual(async_syscall.await_count, 1) + self.assertEqual({5661}, mapped_pids) + + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall", return_value=(0, """ + 1435# 5614# reaper + 5614# 30324# pv-bwrap + 30324# 30408# pressure-vessel + 30408# 5661# Game_x64.exe + 30408# 5662# Game_thread + """)) + async def test_map_pids__should_not_yield_defunct_processes(self, async_syscall: AsyncMock): + cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- ' \ + '/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point ' \ + '--verb=waitforexitandrun -- ' \ + '/home/user/.local/share/Steam/steamapps/common/Proton 6.3/proton waitforexitandrun ' \ + '/home/user/.local/share/Steam/steamapps/common/Game II/Game_x64.exe' + + request = OptimizationRequest(pid=5614, command=cmd, user_name='user') + profile = new_steam_profile(enabled=True) - @patch(f'{__app_name__}.common.system.find_process_by_command', return_value=(456, 'Game_x64.exe')) - async def test_map_pid__return_id_when_proton_command_from_runtime(self, find_process_by_command: Mock): - cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- /home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point --verb=waitforexitandrun -- /home/user/.local/share/Steam/steamapps/common/Proton 6.3/proton waitforexitandrun /home/user/.local/share/Steam/steamapps/common/Game II/Game_x64.exe' + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertGreaterEqual(async_syscall.await_count, 1) + self.assertEqual({5661}, mapped_pids) + + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall", return_value=(0, """ + 1435# 5614# reaper + 5614# 30324# pv-bwrap + 30324# 30408# pressure-vessel + 30408# 5661# Game_x64.exe + 30408# 5662# pressure-vessel + 30408# 5663# pv-bwrap + 30408# 5664# reaper + """)) + async def test_map_pids__should_not_yield_children_with_name_in_hierachy(self, async_syscall: AsyncMock): + cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- ' \ + '/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point ' \ + '--verb=waitforexitandrun -- ' \ + '/home/user/.local/share/Steam/steamapps/common/Proton 6.3/proton waitforexitandrun ' \ + '/home/user/.local/share/Steam/steamapps/common/Game II/Game_x64.exe' + + request = OptimizationRequest(pid=5614, command=cmd, user_name='user') + profile = new_steam_profile(enabled=True) - request = OptimizationRequest(pid=123, command=cmd, user_name='user') + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertGreaterEqual(async_syscall.await_count, 1) + self.assertEqual({5661}, mapped_pids) + + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall", return_value=(0, """ + 1435# 5614# reaper + 5614# 30324# pv-bwrap + 30324# 30408# pressure-vessel + 30408# 5661# Game_x64.exe + 5661# 5662# pressure-vessel + 5662# 5663# pv-bwrap + 5663# 5664# reaper + """)) + async def test_map_pids__should_not_yield_children_of_children_with_name_in_hierachy(self, async_syscall: AsyncMock): + cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- ' \ + '/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point ' \ + '--verb=waitforexitandrun -- ' \ + '/home/user/.local/share/Steam/steamapps/common/Proton 6.3/proton waitforexitandrun ' \ + '/home/user/.local/share/Steam/steamapps/common/Game II/Game_x64.exe' + + request = OptimizationRequest(pid=5614, command=cmd, user_name='user') profile = new_steam_profile(enabled=True) - returned_pid = await self.mapper.map_pid(request=request, profile=profile) - self.assertEqual(456, returned_pid) + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertGreaterEqual(async_syscall.await_count, 1) + self.assertEqual({5661}, mapped_pids) + + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall", return_value=(0, """ + 12# 123# reaper + 123# 456# pv-bwrap + 456# 789# pressure-vessel + 789# 1011# python3 + 789# 1213# Game_x64.exe + 789# 1214# Game_x64-thread + """)) + async def test_map_pids__yield_children_until_timeout_is_reached(self, async_syscall: AsyncMock): + cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- ' \ + '/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point ' \ + '--verb=waitforexitandrun -- ' \ + '/home/user/.local/share/Steam/steamapps/common/Proton 6.3/proton waitforexitandrun ' \ + '/home/user/.local/share/Steam/steamapps/common/Game II/Game_x64.exe' - expected_wine_path = {util.map_any_regex(c) for c in get_proton_exec_name_and_paths(cmd)[1:]} - find_process_by_command.assert_called_once_with(expected_wine_path, last_match=True) + request = OptimizationRequest(pid=123, command=cmd, user_name='user') + profile = new_steam_profile(enabled=True) - @patch(f'{__app_name__}.common.system.find_process_by_name', return_value=(456, 'Game_x64.exe')) - @patch(f'{__app_name__}.common.system.find_process_by_command', return_value=None) - async def test_map_pid__return_id_when_proton_command_patterns_not_match_but_name_does(self, find_process_by_command: AsyncMock, find_process_by_name: AsyncMock): - cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- /home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point --verb=waitforexitandrun -- /home/user/.local/share/Steam/steamapps/common/Proton 6.3/proton waitforexitandrun /home/user/.local/share/Steam/steamapps/common/Game II/Game_x64.exe' + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertGreaterEqual(async_syscall.await_count, 1) + self.assertEqual({1213, 1214}, mapped_pids) + + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall", return_value=(0, """ + 12# 123# reaper + 123# 456# pv-bwrap + 456# 789# pressure-vessel + 789# 1011# python3 + """)) + async def test_map_pids__yield_nothing_when_no_children_is_found(self, async_syscall: AsyncMock): + cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- ' \ + '/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point ' \ + '--verb=waitforexitandrun -- ' \ + '/home/user/.local/share/Steam/steamapps/common/Proton 6.3/proton waitforexitandrun ' \ + '/home/user/.local/share/Steam/steamapps/common/Game II/Game_x64.exe' request = OptimizationRequest(pid=123, command=cmd, user_name='user') profile = new_steam_profile(enabled=True) - returned_pid = await self.mapper.map_pid(request=request, profile=profile) - self.assertEqual(456, returned_pid) + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in self.mapper.map_pids(request, profile)} - find_process_by_command.assert_awaited() - find_process_by_name.assert_awaited_once_with(re.compile(r'^Game_x64.exe$'), last_match=True) + mapped_pids = await map_pids() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertGreaterEqual(async_syscall.await_count, 1) + self.assertEqual(set(), mapped_pids) - @patch(f'{__app_name__}.common.system.find_process_by_command', return_value=(456, 'gm2.sh')) - async def test_map_pid__return_id_when_command_not_from_proton(self, find_process_by_command: Mock): - cmd = '/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=123 -- /home/user/.local/share/Steam/steamapps/common/Game 2/gm2.sh -game tf -steam' + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall") + async def test_map_pids__it_should_stopping_yielding_if_found_check_time_reached(self, *mocks: AsyncMock): + async_syscall = mocks[0] - request = OptimizationRequest(pid=123, command=cmd, user_name='user') + async_syscall.side_effect = [(0, "1403# 2601# reaper\n2601# 2602# ABC.x86_"), + (0, """1403# 2601# reaper + 2601# 2602# ABC.x86_ + 2601# 2603# ABC.x86_-thread""")] + + cmd = "/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=999999 -- " \ + "/home/user/.local/share/Steam/ubuntu12_32/steam-launch-wrapper -- " \ + "/media/hd0/Steam/steamapps/common/Game/ABC.x86_" + + self.mapper = SteamLauncherMapper(check_time=0.1, found_check_time=0, iteration_sleep_time=0.001, logger=Mock()) + request = OptimizationRequest(pid=2601, command=cmd, user_name='user') profile = new_steam_profile(enabled=True) - returned_pid = await self.mapper.map_pid(request=request, profile=profile) - self.assertEqual(456, returned_pid) + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertEqual(1, async_syscall.await_count) + self.assertEqual({2602}, mapped_pids) + + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall", return_value=(0, """ + 1403# 2601# reaper + 2601# 2602# ABC.x86_ + 2602# 2603# ABC.x86_-thread + 2603# 2604# ABC.x86_-thread-2 + """)) + async def test_map_pids__it_should_yield_children_of_target_children(self, async_syscall: AsyncMock): + cmd = "/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=999999 -- " \ + "/home/user/.local/share/Steam/ubuntu12_32/steam-launch-wrapper -- " \ + "/media/hd0/Steam/steamapps/common/Game/ABC.x86_" + + request = OptimizationRequest(pid=2601, command=cmd, user_name='user') + profile = new_steam_profile(enabled=True) - expected_wine_path = {re.compile(r'(/bin/\w+\s+)?{}'.format(re.escape(get_steam_runtime_command(cmd))))} - find_process_by_command.assert_called_once_with(expected_wine_path, last_match=True) + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in self.mapper.map_pids(request, profile)} + + mapped_pids = await map_pids() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertGreaterEqual(async_syscall.await_count, 1) + self.assertEqual({2602, 2603, 2604}, mapped_pids) + + def test_map_expected_hierarchy__when_proton_command(self): + cmd = "/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=443860 -- " \ + "/home/user/.local/share/Steam/ubuntu12_32/steam-launch-wrapper -- " \ + "/media/ssd_01/Steam/steamapps/common/Proton 3.16/proton waitforexitandrun " \ + "/media/ssd_01/Steam/steamapps/common/Game AB Defghij/Game_x64.exe" + request = OptimizationRequest(pid=2601, command=cmd, user_name='user') + + expected_hierarchy = ["python3", "reaper"] + self.assertEqual(expected_hierarchy, self.mapper.map_expected_hierarchy(request, "reaper")) + + def test_map_expected_hierarchy__when_proton_command_executed_from_container(self): + cmd = "/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=622020 -- " \ + "/home/user/.local/share/Steam/ubuntu12_32/steam-launch-wrapper -- " \ + "/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point " \ + "--verb=waitforexitandrun -- /home/user/.local/share/Steam/steamapps/common/Proton 7.0/proton " \ + "waitforexitandrun /media/ssd_02/Steam/steamapps/common/Game Abc & def/ABC.exe" + request = OptimizationRequest(pid=2601, command=cmd, user_name='user') + + expected_hierarchy = ["pressure-vessel", "pv-bwrap", "reaper"] + self.assertEqual(expected_hierarchy, self.mapper.map_expected_hierarchy(request, "reaper")) + + def test_map_expected_hierarchy__when_native_command(self): + cmd = "/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=245170 -- " \ + "/home/user/.local/share/Steam/ubuntu12_32/steam-launch-wrapper -- " \ + "/media/ssd_02/Steam/steamapps/common/Game/Game.x86_64-pc-linux-gnu" + request = OptimizationRequest(pid=2601, command=cmd, user_name='user') + + expected_hierarchy = ["reaper"] + self.assertEqual(expected_hierarchy, self.mapper.map_expected_hierarchy(request, "reaper")) + + def test_map_expected_hierarchy__when_native_command_from_container(self): + cmd = "/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=245170 -- " \ + "/home/user/.local/share/Steam/ubuntu12_32/steam-launch-wrapper -- " \ + "/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point " \ + "--verb=waitforexitandrun -- " \ + "/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime/scout-on-soldier-entry-point-v2 -- " \ + "/media/ssd_02/Steam/steamapps/common/Game/Game.x86_64-pc-linux-gnu" + request = OptimizationRequest(pid=2601, command=cmd, user_name='user') + + expected_hierarchy = ["pressure-vessel", "pv-bwrap", "reaper"] + self.assertEqual(expected_hierarchy, self.mapper.map_expected_hierarchy(request, "reaper")) + + def test_extract_root_process_name__must_return_the_first_command_call_name(self): + cmd = "/home/user/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=622020 -- " \ + "/home/user/.local/share/Steam/ubuntu12_32/steam-launch-wrapper -- " \ + "/home/user/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point " \ + "--verb=waitforexitandrun -- /home/user/.local/share/Steam/steamapps/common/Proton 7.0/proton " \ + "waitforexitandrun /media/ssd_02/Steam/steamapps/common/Game Abc & def/ABC.exe" + + root_cmd = self.mapper.extract_root_process_name(cmd) + self.assertEqual("reaper", root_cmd) + + @patch(f"{__app_name__}.service.optimizer.launcher.async_syscall") + async def test_map_processes_by_parent(self, async_syscall: AsyncMock): + async_syscall.return_value = (0, """ + 1411# 5202# reaper + 5202# 5203# pv-bwrap + 5203# 5286# pressure-vessel + 5286# 7925# python3 + 5286# 8017# wineserver + 5286# 8708# Game.exe + 5286# 8747# Game-Win64-Shi + """) + + proc_map = await self.mapper.map_processes_by_parent() + async_syscall.assert_awaited_with(f'ps -Ao "%P#%p#%c" -ww --no-headers') + self.assertEqual(4, len(proc_map)) + + expected = {1411: {(5202, "reaper")}, + 5202: {(5203, "pv-bwrap")}, + 5203: {(5286, "pressure-vessel")}, + 5286: {(7925, "python3"), (8017, "wineserver"), (8708, "Game.exe"), (8747, "Game-Win64-Shi")}} + self.assertEqual(expected, proc_map) + + def test_find_target_in_hierarchy__return_the_root_id_when_just_one_element_hierarchy(self): + hierarchy = ["reaper"] + pids_by_comm = dict() + + target_id = self.mapper.find_target_in_hierarchy(reverse_hierarchy=hierarchy, root_element_pid=456, + processes_by_parent=dict(), pid_by_comm=pids_by_comm) + self.assertEqual(456, target_id) + + def test_find_target_in_hierarchy__return_the_target_child_id_when_parent_has_children_first_run(self): + hierarchy = ["pressure-vessel", "pv-bwrap", "reaper"] + pids_by_comm = dict() + parent_procs = { + 123: {(456, "reaper")}, + 456: {(789, "pv-bwrap")}, + 789: {(1011, "pressure-vessel")} + } + + target_id = self.mapper.find_target_in_hierarchy(reverse_hierarchy=hierarchy, root_element_pid=456, + processes_by_parent=parent_procs, pid_by_comm=pids_by_comm) + self.assertEqual(1011, target_id) + self.assertEqual({"pressure-vessel": 1011, "pv-bwrap": 789, "reaper": 456}, pids_by_comm) + + def test_find_target_in_hierarchy__return_the_target_child_id_when_parent_has_children_on_second_run(self): + hierarchy = ["pressure-vessel", "pv-bwrap", "reaper"] + pids_by_comm = dict() + first_mapping = { + 123: {(456, "reaper")}, + 456: {(789, "pv-bwrap")}, + } + + target_id_first_run = self.mapper.find_target_in_hierarchy(reverse_hierarchy=hierarchy, root_element_pid=456, + processes_by_parent=first_mapping, + pid_by_comm=pids_by_comm) + self.assertIsNone(target_id_first_run) + self.assertEqual({"pv-bwrap": 789, "reaper": 456}, pids_by_comm) + + second_mapping = {**first_mapping, 789: {(1011, "pressure-vessel")}} + + target_id_second_run = self.mapper.find_target_in_hierarchy(reverse_hierarchy=hierarchy, root_element_pid=456, + processes_by_parent=second_mapping, + pid_by_comm=pids_by_comm) + self.assertEqual(1011, target_id_second_run) + self.assertEqual({"pressure-vessel": 1011, "pv-bwrap": 789, "reaper": 456}, pids_by_comm) + + def test_find_target_in_hierarchy__return_the_latest_target_child_id_when_multiple_matches(self): + hierarchy = ["pressure-vessel", "pv-bwrap", "reaper"] + pids_by_comm = dict() + parent_procs = { + 123: {(456, "reaper")}, + 456: {(789, "pv-bwrap")}, + 789: {(1011, "pressure-vessel"), (1012, "pressure-vessel")} + } + + target_id = self.mapper.find_target_in_hierarchy(reverse_hierarchy=hierarchy, root_element_pid=456, + processes_by_parent=parent_procs, pid_by_comm=pids_by_comm) + self.assertEqual(1012, target_id) + self.assertEqual({"pressure-vessel": 1012, "pv-bwrap": 789, "reaper": 456}, pids_by_comm) + + def test_to_ignore__must_contain_ea_origin_processes(self): + expected_processes = {"OriginWebHelper", "Origin.exe", "OriginClientSer", "QtWebEngineProc", + "EASteamProxy.ex", "UPlayBrowser.exe", "ldconfig", "EALink.exe", "OriginLegacyCLI", + "IGOProxy.exe", "IGOProxy64.exe", "igoproxy64.exe", "ActivationUI.ex"} + + self.assertTrue(expected_processes.issubset(self.mapper.to_ignore)) + + def test_to_ignore__must_contain_wine_processes(self): + expected_processes = {"wineserver", "services.exe", "winedevice.exe", "plugplay.exe", "svchost.exe", + "explorer.exe", "rpcss.exe", "tabtip.exe", "wine", "wine64", "wineboot.exe", + "cmd.exe", "conhost.exe", "start.exe"} + + self.assertTrue(expected_processes.issubset(self.mapper.to_ignore)) + + def test_to_ignore__must_contain_proton_processes(self): + expected_processes = {"steam-runtime-l", "proton", "gzip", "steam.exe", "python", "python3"} + self.assertTrue(expected_processes.issubset(self.mapper.to_ignore)) + + def test_to_ignore__must_contain_anticheat_processes(self): + expected_processes = {"PnkBstrA.exe"} + self.assertTrue(expected_processes.issubset(self.mapper.to_ignore)) + + def test_to_ignore__must_contain_unknown_unneeded_processes(self): + expected_processes = {"whql:off"} + self.assertTrue(expected_processes.issubset(self.mapper.to_ignore)) + + def test_find_children__it_should_find_children_of_already_found_processes(self): + parent_procs = { + 123: {(456, "reaper")}, + 456: {(789, "pv-bwrap")}, + 789: {(1011, "abc"), (1012, "def")} + } + + already_found = {789} + new_found = {p for p in self.mapper.find_children(ppid=456, + processes_by_parent=parent_procs, + already_found=already_found)} + self.assertEqual({1011, 1012}, new_found) class ProcessLauncherManagerTest(IsolatedAsyncioTestCase): - async def test_map_pid__should_try_to_retrieve_pid_until_a_mapper_returns_a_pid(self): + async def test_map_pids__should_try_to_retrieve_pid_until_a_mapper_returns_a_pid(self): # first sub-mapper that would inspect the request, but no process would be returned mocked_mapper = Mock() - mocked_mapper.map_pid = MagicMock(return_value=Future()) - mocked_mapper.map_pid.return_value.set_result(None) + mocked_mapper.map_pids = Mock(return_value=AsyncIterator([])) # second sub-mapper that would inspect the request and actually find the process - steam_mapper = SteamLauncherMapper(wait_time=30, logger=Mock()) - steam_mapper.map_pid = MagicMock(return_value=Future()) - steam_mapper.map_pid.return_value.set_result(456) + steam_mapper = SteamLauncherMapper(check_time=30, found_check_time=0, logger=Mock()) + steam_mapper.map_pids = Mock(return_value=AsyncIterator([456])) - manager = LauncherMapperManager(check_time=30, logger=Mock(), mappers=[mocked_mapper, steam_mapper]) + manager = LauncherMapperManager(check_time=30, found_check_time=0, logger=Mock(), + mappers=(mocked_mapper, steam_mapper)) request = OptimizationRequest(pid=123, command='/abc', user_name='user') profile = new_steam_profile(enabled=True) - real_id = await manager.map_pid(request=request, profile=profile) - self.assertEqual(456, real_id) - mocked_mapper.map_pid.assert_called_once() - steam_mapper.map_pid.assert_called_once() + async def map_pids() -> Optional[Set[int]]: + return {pid async for pid in manager.map_pids(request, profile)} + + self.assertEqual({456}, await map_pids()) + + mocked_mapper.map_pids.assert_called_once() + steam_mapper.map_pids.assert_called_once() async def test_get_sub_mappers__order(self): - manager = LauncherMapperManager(check_time=30, logger=Mock()) + manager = LauncherMapperManager(check_time=30, found_check_time=0, logger=Mock()) mappers = manager.get_sub_mappers() self.assertIsNotNone(mappers) self.assertEqual(2, len(mappers)) diff --git a/tests/service/watcher/test_core.py b/tests/service/watcher/test_core.py index 8fb2244..9faad99 100644 --- a/tests/service/watcher/test_core.py +++ b/tests/service/watcher/test_core.py @@ -1,6 +1,6 @@ import re from unittest import IsolatedAsyncioTestCase -from unittest.mock import Mock, patch, call +from unittest.mock import Mock, patch, call, AsyncMock from guapow import __app_name__ from guapow.common.config import OptimizerConfig @@ -14,7 +14,8 @@ class ProcessWatcherTest(IsolatedAsyncioTestCase): def setUp(self): self.context = ProcessWatcherContext(user_id=1, user_name='xpto', user_env={'a': '1'}, logger=Mock(), - mapping_file_path='test.map', optimized={}, opt_config=OptimizerConfig.default(), + mapping_file_path='test.map', optimized={}, + opt_config=OptimizerConfig.default(), watch_config=ProcessWatcherConfig.default(), machine_id='abc126517ha', ignored_procs=dict(), ignored_file_path='test.ignore') self.watcher = ProcessWatcher(regex_mapper=RegexMapper(cache=False, logger=Mock()), context=self.context) @@ -22,9 +23,13 @@ def setUp(self): @patch(f'{__app_name__}.service.watcher.core.mapping.read', return_value=(False, None)) @patch(f'{__app_name__}.service.watcher.core.ignored.read', return_value=(False, None)) @patch(f'{__app_name__}.service.watcher.core.map_processes') - async def test_check_mappings__must_not_map_processes_when_no_mapping_is_found(self, map_processes: Mock, ignored_read: Mock, mapping_read: Mock): + async def test_check_mappings__must_not_map_processes_when_no_mapping_is_found(self, *mocks: AsyncMock): + map_processes, ignored_read, mapping_read = mocks + await self.watcher.check_mappings() - mapping_read.assert_called_once_with(file_path=self.context.mapping_file_path, logger=self.context.logger, last_file_found_log=None) + mapping_read.assert_called_once_with(file_path=self.context.mapping_file_path, + logger=self.context.logger, + last_file_found_log=None) ignored_read.assert_not_called() map_processes.assert_not_called() @@ -328,15 +333,23 @@ async def test_check_mappings__more_specific_comm_match_must_prevail_over_others send_async.assert_has_calls([call(req_a, self.context.opt_config, self.context.machine_id, self.context.logger)], any_order=True) self.assertEqual({1: 'abacaxi'}, self.context.optimized) - @patch(f'{__app_name__}.service.watcher.core.mapping.read', return_value=(True, {'a*': 'default', '/bin/*': 'prof_1', '/local/d': 'prof_2'})) - @patch(f'{__app_name__}.service.watcher.core.ignored.read', return_value=(False, None)) - @patch(f'{__app_name__}.service.watcher.core.map_processes', side_effect=[{1: ('/local/bin/abacaxi', 'abacaxi'), - 2: ('/bin/b', 'b'), - 3: ('/bin/c', 'c')}, - {4: ('/xpto', 'xpto')}]) - @patch(f'{__app_name__}.service.watcher.core.network.send', return_value=True) - @patch(f'{__app_name__}.service.watcher.core.time.time', return_value=12345) - async def test_check_mappings__must_cache_mapping_when_defined_on_config(self, time: Mock, send_async: Mock, map_processes: Mock, ignored_read: Mock, mapping_read: Mock): + @patch(f'{__app_name__}.service.watcher.core.mapping.read') + @patch(f'{__app_name__}.service.watcher.core.ignored.read') + @patch(f'{__app_name__}.service.watcher.core.map_processes') + @patch(f'{__app_name__}.service.watcher.core.network.send') + @patch(f'{__app_name__}.service.watcher.core.time.time') + async def test_check_mappings__must_cache_mapping_when_defined_on_config(self, *mocks: AsyncMock): + time, send, map_processes, ignored_read, mapping_read = mocks + + mapping_read.return_value = (True, {'a*': 'default', '/bin/*': 'prof_1', '/local/d': 'prof_2'}) + ignored_read.return_value = (False, None) + map_processes.side_effect = [{1: ('/local/bin/abacaxi', 'abacaxi'), + 2: ('/bin/b', 'b'), + 3: ('/bin/c', 'c')}, + {4: ('/xpto', 'xpto')}] + send.return_value = True + time.return_value = 12345 + self.assertIsNone(self.watcher._mappings) self.assertIsNone(self.watcher._cmd_patterns) self.assertIsNone(self.watcher._comm_patterns) @@ -354,15 +367,16 @@ async def test_check_mappings__must_cache_mapping_when_defined_on_config(self, t req_c = OptimizationRequest(pid=3, command='/bin/c', user_name=self.context.user_name, user_env=self.context.user_env, profile='prof_1', created_at=12345) - send_async.assert_has_calls([call(req_a, self.context.opt_config, self.context.machine_id, self.context.logger), - call(req_b, self.context.opt_config, self.context.machine_id, self.context.logger), - call(req_c, self.context.opt_config, self.context.machine_id, self.context.logger)], any_order=True) + send.assert_has_calls([call(req_a, self.context.opt_config, self.context.machine_id, self.context.logger), + call(req_b, self.context.opt_config, self.context.machine_id, self.context.logger), + call(req_c, self.context.opt_config, self.context.machine_id, self.context.logger)], + any_order=True) self.assertEqual({1: 'abacaxi', 2: '/bin/b', 3: '/bin/c'}, self.context.optimized) self.assertEqual({'a*': 'default', '/bin/*': 'prof_1', '/local/d': 'prof_2'}, self.watcher._mappings) - self.assertEqual({re.compile(r'^/bin/.+$'): 'prof_1'}, self.watcher._cmd_patterns) - self.assertEqual({re.compile(r'^a.+$'): 'default'}, self.watcher._comm_patterns) + self.assertEqual({re.compile(r'^/bin/.*$'): 'prof_1'}, self.watcher._cmd_patterns) + self.assertEqual({re.compile(r'^a.*$'): 'default'}, self.watcher._comm_patterns) # second call await self.watcher.check_mappings() @@ -449,8 +463,8 @@ async def test_check_mappings__must_not_perform_any_request_if_ignored_matches(s map_processes.assert_called_once() send_async.assert_not_called() self.assertEqual({}, self.context.optimized) - self.assertEqual({re.compile(r'^de.+$'): {'123:def'}, - re.compile(r'^/bin/a.+$'): {'456:abc'}, + self.assertEqual({re.compile(r'^de.*$'): {'123:def'}, + re.compile(r'^/bin/a.*$'): {'456:abc'}, 'ghi': {'789:ghi'}}, self.context.ignored_procs) self.assertFalse(self.watcher._ignored_cached) # ensuring nothing was cached (when disabled) @@ -487,8 +501,8 @@ async def test_check_mappings__must_not_perform_any_request_if_only_exact_ignore async def test_check_mappings__must_clean_ignored_context_when_pattern_is_no_long_returned(self, *mocks: Mock): send_async, map_processes, ignored_read, mapping_read = mocks - self.context.ignored_procs.update({re.compile(r'^de.+$'): {'123:def'}, - re.compile(r'^/bin/a.+$'): {'456:abc'}, + self.context.ignored_procs.update({re.compile(r'^de.*$'): {'123:def'}, + re.compile(r'^/bin/a.*$'): {'456:abc'}, 'ghi': {'789:ghi'}}) await self.watcher.check_mappings() mapping_read.assert_called_once_with(file_path=self.context.mapping_file_path, @@ -498,7 +512,7 @@ async def test_check_mappings__must_clean_ignored_context_when_pattern_is_no_lon map_processes.assert_called_once() self.assertEqual(2, send_async.call_count) self.assertEqual(2, len(self.context.optimized)) - self.assertEqual({re.compile(r'^de.+$'): {'123:def'}}, self.context.ignored_procs) + self.assertEqual({re.compile(r'^de.*$'): {'123:def'}}, self.context.ignored_procs) @patch(f'{__app_name__}.service.watcher.core.mapping.read', return_value=(True, {'def': 'default', 'abc': 'default'})) @@ -508,8 +522,8 @@ async def test_check_mappings__must_clean_ignored_context_when_pattern_is_no_lon async def test_check_mappings__must_clean_ignored_context_when_ignored_proc_stops(self, *mocks: Mock): send_async, map_processes, ignored_read, mapping_read = mocks - self.context.ignored_procs.update({r'^de.+$': {'123:def'}, - r'^/bin/a.+$': {'456:abc'}, + self.context.ignored_procs.update({r'^de.*$': {'123:def'}, + r'^/bin/a.*$': {'456:abc'}, 'ghi': {'789:ghi'}}) await self.watcher.check_mappings() mapping_read.assert_called_once_with(file_path=self.context.mapping_file_path, @@ -519,7 +533,7 @@ async def test_check_mappings__must_clean_ignored_context_when_ignored_proc_stop map_processes.assert_called_once() self.assertEqual(0, send_async.call_count) self.assertEqual(0, len(self.context.optimized)) - self.assertEqual({re.compile(r'^de.+$'): {'123:def'}}, self.context.ignored_procs) + self.assertEqual({re.compile(r'^de.*$'): {'123:def'}}, self.context.ignored_procs) @patch(f'{__app_name__}.service.watcher.core.mapping.read', return_value=(True, {'def': 'default', 'abc': 'default', @@ -543,15 +557,15 @@ async def test_check_mappings__must_cache_all_types_of_ignored_mappings_if_enabl self.assertTrue(self.watcher._ignored_cached) self.assertEqual({'ghi', 'de*', '/bin/a*'}, self.watcher._ignored_exact_strs) - self.assertEqual({re.compile(r'^/bin/a.+$')}, self.watcher._ignored_cmd_patterns) - self.assertEqual({re.compile(r'^de.+$')}, self.watcher._ignored_comm_patterns) + self.assertEqual({re.compile(r'^/bin/a.*$')}, self.watcher._ignored_cmd_patterns) + self.assertEqual({re.compile(r'^de.*$')}, self.watcher._ignored_comm_patterns) await self.watcher.check_mappings() # second call self.assertTrue(self.watcher._ignored_cached) self.assertEqual({'ghi', 'de*', '/bin/a*'}, self.watcher._ignored_exact_strs) - self.assertEqual({re.compile(r'^/bin/a.+$')}, self.watcher._ignored_cmd_patterns) - self.assertEqual({re.compile(r'^de.+$')}, self.watcher._ignored_comm_patterns) + self.assertEqual({re.compile(r'^/bin/a.*$')}, self.watcher._ignored_cmd_patterns) + self.assertEqual({re.compile(r'^de.*$')}, self.watcher._ignored_comm_patterns) ignored_read.assert_called_once() self.assertEqual(2, mapping_read.call_count) @@ -559,8 +573,8 @@ async def test_check_mappings__must_cache_all_types_of_ignored_mappings_if_enabl send_async.assert_not_called() self.assertEqual({}, self.context.optimized) - self.assertEqual({re.compile(r'^de.+$'): {'123:def'}, - re.compile(r'^/bin/a.+$'): {'456:abc'}, + self.assertEqual({re.compile(r'^de.*$'): {'123:def'}, + re.compile(r'^/bin/a.*$'): {'456:abc'}, 'ghi': {'789:ghi'}}, self.context.ignored_procs) @patch(f'{__app_name__}.service.watcher.core.mapping.read', return_value=(True, {'def': 'default', diff --git a/tests/service/watcher/test_patterns.py b/tests/service/watcher/test_patterns.py index 9d8bc1f..6987b72 100644 --- a/tests/service/watcher/test_patterns.py +++ b/tests/service/watcher/test_patterns.py @@ -15,8 +15,8 @@ def test_map_for_profiles__must_return_pattern_keys_only_for_strings_with_asteri cmd_profs = {'abc': 'default', '/*/xpto': 'prof', 'def*abc*': 'prof2'} pattern_mappings = self.mapper.map_for_profiles(cmd_profs) self.assertIsInstance(pattern_mappings, tuple) - self.assertEqual({re.compile(r'^/.+/xpto$'): 'prof'}, pattern_mappings[0]) # cmd - self.assertEqual({re.compile(r'^def.+abc.+$'): 'prof2'}, pattern_mappings[1]) # comm + self.assertEqual({re.compile(r'^/.*/xpto$'): 'prof'}, pattern_mappings[0]) # cmd + self.assertEqual({re.compile(r'^def.*abc.*$'): 'prof2'}, pattern_mappings[1]) # comm def test_map_for_profiles__must_return_pattern_keys_when_key_starts_with_python_regex_pattern(self): cmd_profs = {'abc': 'default', 'r:/.+/xpto': 'prof', 'r:def.+abc\d+': 'prof2'} @@ -39,7 +39,7 @@ def test_map_for_profiles__must_cache_a_valid_pattern_when_cache_is_true(self): self.assertIsNone(self.mapper.get_cached_pattern('abc')) self.assertEqual(re.compile(r'^/.+/xpto$'), self.mapper.get_cached_pattern('r:/.+/xpto')) - self.assertEqual(re.compile(r'^def.+ihk.+$'), self.mapper.get_cached_pattern('def*ihk*')) + self.assertEqual(re.compile(r'^def.*ihk.*$'), self.mapper.get_cached_pattern('def*ihk*')) self.assertTrue(self.mapper.is_cached_as_no_pattern('abc'))