You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
470 lines
17 KiB
Python
470 lines
17 KiB
Python
"""
|
|
Copyright (c) Contributors to the Open 3D Engine Project.
|
|
For complete copyright and license terms please see the LICENSE at the root of this distribution.
|
|
|
|
SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
|
|
Process management functions, to supplement normal use of psutil and subprocess
|
|
"""
|
|
import logging
|
|
import os
|
|
import psutil
|
|
import subprocess
|
|
import ctypes
|
|
|
|
import ly_test_tools.environment.waiter as waiter
|
|
from ly_test_tools import WINDOWS, MAC
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_PROCESS_OUTPUT_ENCODING = 'utf-8'
|
|
|
|
# Default list of processes names to kill
|
|
LY_PROCESS_KILL_LIST = [
|
|
'CrySCompileServer', 'Editor',
|
|
'Profiler', 'RemoteConsole',
|
|
'rc' # Resource Compiler
|
|
]
|
|
|
|
|
|
def kill_processes_named(names, ignore_extensions=False):
|
|
"""
|
|
Kills all processes with a given name
|
|
|
|
:param names: string process name, or list of strings of process name
|
|
:param ignore_extensions: ignore trailing file extension
|
|
"""
|
|
if not names:
|
|
return
|
|
|
|
names = [names] if isinstance(names, str) else names
|
|
|
|
if ignore_extensions:
|
|
names = [_remove_extension(name) for name in names]
|
|
|
|
# remove any blank names, which may empty the list
|
|
names = list(filter(lambda x: not x.isspace(), names))
|
|
if not names:
|
|
return
|
|
|
|
logger.info(f"Killing all processes named {names}")
|
|
process_list_to_kill = []
|
|
for process in _safe_get_processes(['name', 'pid']):
|
|
try:
|
|
proc_name = process.name()
|
|
except psutil.AccessDenied:
|
|
logger.info(f"Process {process} permissions error during kill_processes_named()", exc_info=True)
|
|
continue
|
|
except psutil.ProcessLookupError:
|
|
logger.debug(f"Process {process} could not be killed during kill_processes_named() and was likely already stopped", exc_info=True)
|
|
continue
|
|
except psutil.NoSuchProcess:
|
|
logger.debug(f"Process '{process}' was active when list of processes was requested but it was not found "
|
|
f"during kill_processes_named()", exc_info=True)
|
|
continue
|
|
|
|
if ignore_extensions:
|
|
proc_name = _remove_extension(proc_name)
|
|
|
|
if proc_name in names:
|
|
logger.debug(f"Found process with name {proc_name}. Attempting to kill...")
|
|
process_list_to_kill.append(process)
|
|
|
|
_safe_kill_process_list(process_list_to_kill)
|
|
|
|
|
|
def kill_processes_started_from(path):
|
|
"""
|
|
Kills all processes started from a given directory or executable
|
|
|
|
:param path: path to application or directory
|
|
"""
|
|
logger.info(f"Killing processes started from '{path}'")
|
|
if os.path.exists(path):
|
|
process_list = []
|
|
for process in _safe_get_processes():
|
|
try:
|
|
process_path = process.exe()
|
|
except (psutil.AccessDenied, psutil.NoSuchProcess):
|
|
continue
|
|
|
|
if process_path.lower().startswith(path.lower()):
|
|
process_list.append(process)
|
|
|
|
_safe_kill_process_list(process_list)
|
|
else:
|
|
logger.warning(f"Path:'{path}' not found")
|
|
|
|
|
|
def kill_processes_with_name_not_started_from(name, path):
|
|
"""
|
|
Kills all processes with a given name that NOT started from a directory or executable
|
|
|
|
:param name: name of application to look for
|
|
:param path: path where process shouldn't have started from
|
|
"""
|
|
path = os.path.join(os.getcwd(), os.path.normpath(path)).lower()
|
|
logger.info(f"Killing processes with name:'{name}' not started from '{path}'")
|
|
if os.path.exists(path):
|
|
proccesses_to_kill = []
|
|
for process in _safe_get_processes(["name", "pid"]):
|
|
try:
|
|
process_path = process.exe()
|
|
except (psutil.AccessDenied, psutil.NoSuchProcess) as ex:
|
|
continue
|
|
|
|
process_name = os.path.splitext(os.path.basename(process_path))[0]
|
|
|
|
if process_name == os.path.basename(name) and not os.path.dirname(process_path.lower()) == path:
|
|
logger.info("%s -> %s" % (os.path.dirname(process_path.lower()), path))
|
|
proccesses_to_kill.append(process)
|
|
|
|
_safe_kill_process_list(proccesses_to_kill)
|
|
else:
|
|
logger.warning(f"Path:'{path}' not found")
|
|
|
|
|
|
def kill_process_with_pid(pid, raise_on_missing=False):
|
|
"""
|
|
Kills the process with the specified pid
|
|
|
|
:param pid: the pid of the process to kill
|
|
:param raise_on_missing: if set to True, raise RuntimeError if the process does not already exist
|
|
"""
|
|
if pid is None:
|
|
logger.warning("Killing process id of 'None' will terminate the current python process!")
|
|
logger.info(f"Killing processes with id '{pid}'")
|
|
process = psutil.Process(pid)
|
|
if process.is_running():
|
|
_safe_kill_process(process)
|
|
elif raise_on_missing:
|
|
message = f"Process with id {pid} was not present"
|
|
logger.error(message)
|
|
raise RuntimeError(message)
|
|
|
|
|
|
def process_exists(name, ignore_extensions=False):
|
|
"""
|
|
Determines whether a process with the given name exists
|
|
|
|
:param name: process name
|
|
:param ignore_extensions: ignore trailing file extension
|
|
:return: A boolean determining whether the process is alive or not
|
|
"""
|
|
name = name.lower()
|
|
if ignore_extensions:
|
|
name = _remove_extension(name)
|
|
if name.isspace():
|
|
return False
|
|
for process in _safe_get_processes(["name"]):
|
|
try:
|
|
proc_name = process.name().lower()
|
|
except psutil.NoSuchProcess as e:
|
|
logger.debug(f"Process '{process}' was active when list of processes was requested but it was not found "
|
|
f"during process_exists()", exc_info=True)
|
|
continue
|
|
except psutil.AccessDenied as e:
|
|
logger.info(f"Permissions issue on {process} during process_exists check", exc_info=True)
|
|
continue
|
|
if ignore_extensions:
|
|
proc_name = _remove_extension(proc_name)
|
|
if proc_name == name:
|
|
return True
|
|
return False
|
|
|
|
|
|
def process_is_unresponsive(name):
|
|
"""
|
|
Check if the specified process is unresponsive.
|
|
|
|
Mac warning: this method assumes that a process is not responsive if it is sleeping or waiting, this is true for
|
|
'active' applications, but may not be the case for power optimized applications.
|
|
|
|
:param name: the name of the process to check
|
|
:return: True if the specified process is unresponsive and False otherwise
|
|
"""
|
|
if WINDOWS:
|
|
output = check_output(['tasklist',
|
|
'/FI', f'IMAGENAME eq {name}',
|
|
'/FI', 'STATUS eq NOT RESPONDING'])
|
|
output = output.split(os.linesep)
|
|
for line in output:
|
|
if line and name.startswith(line.split()[0]):
|
|
logger.debug(f"Process '{name}' was unresponsive.")
|
|
logger.debug(line)
|
|
return True
|
|
logger.debug(f"Process '{name}' was not unresponsive.")
|
|
return False
|
|
elif MAC:
|
|
cmd = ["ps", "-axc", "-o", "command,state"]
|
|
output = check_output(cmd)
|
|
for line in output.splitlines()[1:]:
|
|
info = [l.strip() for l in line.split(" ") if l.strip() != '']
|
|
state = info[-1]
|
|
pname = " ".join(info[0:-1])
|
|
|
|
if pname == name:
|
|
logger.debug(f"{pname}: {state}")
|
|
if "R" not in state:
|
|
logger.debug(f"Process {name} was unresponsive.")
|
|
return True
|
|
logger.debug(f"Process '{name}' was not unresponsive.")
|
|
return False
|
|
else:
|
|
raise NotImplementedError('Only Windows and Mac hosts are supported.')
|
|
|
|
|
|
def check_output(command, **kwargs):
|
|
"""
|
|
Forwards arguments to subprocess.check_output so better error messages can be displayed upon failure.
|
|
|
|
If you need the stderr output from a failed process then pass in stderr=subprocess.STDOUT as a kwarg.
|
|
|
|
:param command: A list of the command to execute and its arguments as split by whitespace.
|
|
:param kwargs: Keyword args forwarded to subprocess.check_output.
|
|
:return: Output from the command if it succeeds.
|
|
"""
|
|
cmd_string = command
|
|
if type(command) == list:
|
|
cmd_string = ' '.join(command)
|
|
|
|
logger.info(f'Executing "check_output({cmd_string})"')
|
|
try:
|
|
output = subprocess.check_output(command, **kwargs).decode(_PROCESS_OUTPUT_ENCODING)
|
|
except subprocess.CalledProcessError as e:
|
|
logger.error(f'Command "{cmd_string}" failed with returncode {e.returncode}, output:\n{e.output}')
|
|
raise
|
|
logger.info(f'Successfully executed "check_output({cmd_string})"')
|
|
return output
|
|
|
|
|
|
def safe_check_output(command, **kwargs):
|
|
"""
|
|
Forwards arguments to subprocess.check_output so better error messages can be displayed upon failure.
|
|
This function eats the subprocess.CalledProcessError exception upon command failure and returns the output.
|
|
|
|
If you need the stderr output from a failed process then pass in stderr=subprocess.STDOUT as a kwarg.
|
|
|
|
:param command: A list of the command to execute and its arguments as split by whitespace.
|
|
:param kwargs: Keyword args forwarded to subprocess.check_output.
|
|
:return: Output from the command regardless of its return value.
|
|
"""
|
|
cmd_string = command
|
|
if type(command) == list:
|
|
cmd_string = ' '.join(command)
|
|
|
|
logger.info(f'Executing "check_output({cmd_string})"')
|
|
try:
|
|
output = subprocess.check_output(command, **kwargs).decode(_PROCESS_OUTPUT_ENCODING)
|
|
except subprocess.CalledProcessError as e:
|
|
output = e.output
|
|
logger.warning(f'Command "{cmd_string}" failed with returncode {e.returncode}, output:\n{e.output}')
|
|
else:
|
|
logger.info(f'Successfully executed "check_output({cmd_string})"')
|
|
return output
|
|
|
|
|
|
def check_call(command, **kwargs):
|
|
"""
|
|
Forwards arguments to subprocess.check_call so better error messages can be displayed upon failure.
|
|
|
|
:param command: A list of the command to execute and its arguments as if split by whitespace.
|
|
:param kwargs: Keyword args forwarded to subprocess.check_call.
|
|
:return: An exitcode of 0 if the call succeeds.
|
|
"""
|
|
cmd_string = command
|
|
if type(command) == list:
|
|
cmd_string = ' '.join(command)
|
|
|
|
logger.info(f'Executing "check_call({cmd_string})"')
|
|
try:
|
|
subprocess.check_call(command, **kwargs)
|
|
except subprocess.CalledProcessError as e:
|
|
logger.error(f'Command "{cmd_string}" failed with returncode {e.returncode}')
|
|
raise
|
|
logger.info(f'Successfully executed "check_call({cmd_string})"')
|
|
return 0
|
|
|
|
|
|
def safe_check_call(command, **kwargs):
|
|
"""
|
|
Forwards arguments to subprocess.check_call so better error messages can be displayed upon failure.
|
|
This function eats the subprocess.CalledProcessError exception upon command failure and returns the exit code.
|
|
|
|
:param command: A list of the command to execute and its arguments as if split by whitespace.
|
|
:param kwargs: Keyword args forwarded to subprocess.check_call.
|
|
:return: An exitcode of 0 if the call succeeds, otherwise the exitcode returned from the failed subprocess call.
|
|
"""
|
|
cmd_string = command
|
|
if type(command) == list:
|
|
cmd_string = ' '.join(command)
|
|
|
|
logger.info(f'Executing "check_call({cmd_string})"')
|
|
try:
|
|
subprocess.check_call(command, **kwargs)
|
|
except subprocess.CalledProcessError as e:
|
|
logger.warning(f'Command "{cmd_string}" failed with returncode {e.returncode}')
|
|
return e.returncode
|
|
else:
|
|
logger.info(f'Successfully executed "check_call({cmd_string})"')
|
|
return 0
|
|
|
|
|
|
def _safe_get_processes(attrs=None):
|
|
"""
|
|
Returns the process iterator without raising an error if the process list changes
|
|
|
|
:return: The process iterator
|
|
"""
|
|
processes = None
|
|
max_attempts = 10
|
|
for _ in range(max_attempts):
|
|
try:
|
|
processes = psutil.process_iter(attrs)
|
|
break
|
|
except (psutil.Error, RuntimeError):
|
|
logger.debug("Unexpected error", exc_info=True)
|
|
continue
|
|
return processes
|
|
|
|
|
|
def _safe_kill_process(proc):
|
|
"""
|
|
Kills a given process without raising an error
|
|
|
|
:param proc: The process to kill
|
|
"""
|
|
try:
|
|
logger.info(f"Terminating process '{proc.name()}' with id '{proc.pid}'")
|
|
_terminate_and_confirm_dead(proc)
|
|
except psutil.AccessDenied:
|
|
logger.warning("Termination failed, Access Denied", exc_info=True)
|
|
except psutil.NoSuchProcess:
|
|
logger.debug("Termination request ignored, process was already terminated during iteration", exc_info=True)
|
|
except Exception: # purposefully broad
|
|
logger.warning("Unexpected exception while terminating process", exc_info=True)
|
|
|
|
def _safe_kill_process_list(proc_list):
|
|
"""
|
|
Kills a given process without raising an error
|
|
|
|
:param proc_list: The process list to kill
|
|
"""
|
|
|
|
def on_terminate(proc):
|
|
print(f"process '{proc.name()}' with id '{proc.pid}' terminated with exit code {proc.returncode}")
|
|
|
|
for proc in proc_list:
|
|
try:
|
|
logger.info(f"Terminating process '{proc.name()}' with id '{proc.pid}'")
|
|
proc.kill()
|
|
except psutil.AccessDenied:
|
|
logger.warning("Termination failed, Access Denied", exc_info=True)
|
|
except psutil.NoSuchProcess:
|
|
logger.debug("Termination request ignored, process was already terminated during iteration", exc_info=True)
|
|
except Exception: # purposefully broad
|
|
logger.warning("Unexpected exception while terminating process", exc_info=True)
|
|
|
|
try:
|
|
psutil.wait_procs(proc_list, timeout=30, callback=on_terminate)
|
|
except Exception: # purposefully broad
|
|
logger.warning("Unexpected exception while waiting for process to terminate", exc_info=True)
|
|
|
|
|
|
def _terminate_and_confirm_dead(proc):
|
|
"""
|
|
Kills a process and waits for the process to stop running.
|
|
|
|
:param proc: A process to kill, and wait for proper termination
|
|
"""
|
|
def killed():
|
|
return not proc.is_running()
|
|
|
|
proc.kill()
|
|
waiter.wait_for(killed, exc=RuntimeError("Process did not terminate after kill command"))
|
|
|
|
|
|
def _remove_extension(filename):
|
|
"""
|
|
Returns a file name without its extension
|
|
|
|
:param filename: The name of a file
|
|
:return: The name of the file without the extension
|
|
"""
|
|
return filename.rsplit(".", 1)[0]
|
|
|
|
|
|
def close_windows_process(pid, timeout=20, raise_on_missing=False):
|
|
# type: (int, int, bool) -> None
|
|
"""
|
|
Closes a window using the windows api and checks the return code. An error will be raised if the window hasn't
|
|
closed after the timeout duration.
|
|
Note: This is for Windows only and will fail on any other OS
|
|
|
|
:param pid: The process id of the process window to close
|
|
:param timeout: How long to wait for the window to close (seconds)
|
|
:return: None
|
|
:param pid: the pid of the process to kill
|
|
:param raise_on_missing: if set to True, raise RuntimeError if the process does not already exist
|
|
"""
|
|
if not WINDOWS:
|
|
raise NotImplementedError("close_windows_process() is only implemented on Windows.")
|
|
|
|
if pid is None:
|
|
raise TypeError("Cannot close window with pid of None")
|
|
|
|
if not psutil.Process(pid).is_running():
|
|
if raise_on_missing:
|
|
message = f"Process with id {pid} was unexpectedly not present"
|
|
logger.error(message)
|
|
raise RuntimeError(message)
|
|
else:
|
|
logger.warning(f"Process with id {pid} was not present but option raise_on_missing is disabled. Unless "
|
|
f"a matching process gets opened, calling close_windows_process will spin until its timeout")
|
|
|
|
# Gain access to windows api
|
|
user32 = ctypes.windll.user32
|
|
# Set up C data types and function params
|
|
WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.wintypes.BOOL,
|
|
ctypes.wintypes.HWND,
|
|
ctypes.wintypes.LPARAM)
|
|
user32.EnumWindows.argtypes = [
|
|
WNDENUMPROC,
|
|
ctypes.wintypes.LPARAM]
|
|
user32.GetWindowTextLengthW.argtypes = [
|
|
ctypes.wintypes.HWND]
|
|
|
|
# This is called for each process window
|
|
def _close_matched_process_window(hwnd, _):
|
|
# type: (ctypes.wintypes.HWND, int) -> bool
|
|
"""
|
|
EnumWindows() takes a function argument that will return True/False to keep iterating or not.
|
|
|
|
Checks the windows handle's pid against the given pid. If they match, then the window will be closed and
|
|
returns False.
|
|
|
|
:param hwnd: A windows handle to check against the pid
|
|
:param _: Unused buffer parameter
|
|
:return: False if process was found and closed, else True
|
|
"""
|
|
# Get the process id of the handle
|
|
lpdw_process_id = ctypes.c_ulong()
|
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(lpdw_process_id))
|
|
process_id = lpdw_process_id.value
|
|
# Compare to the process id
|
|
if pid == process_id:
|
|
# Close the window
|
|
WM_CLOSE = 16 # System message for closing window: 0x10
|
|
user32.PostMessageA(hwnd, WM_CLOSE, 0, 0)
|
|
# Found window for process id, stop iterating
|
|
return False
|
|
# Process not found, keep looping
|
|
return True
|
|
|
|
# Call the function on all of the handles
|
|
close_process_func = WNDENUMPROC(_close_matched_process_window)
|
|
user32.EnumWindows(close_process_func, 0)
|
|
|
|
# Wait for asyncronous termination
|
|
waiter.wait_for(lambda: pid not in psutil.pids(), timeout=timeout,
|
|
exc=TimeoutError(f"Process {pid} never terminated"))
|