process_utils.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. """
  2. Copyright (c) Contributors to the Open 3D Engine Project.
  3. For complete copyright and license terms please see the LICENSE at the root of this distribution.
  4. SPDX-License-Identifier: Apache-2.0 OR MIT
  5. Process management functions, to supplement normal use of psutil and subprocess
  6. """
  7. import logging
  8. import os
  9. import psutil
  10. import subprocess
  11. import ctypes
  12. import ly_test_tools
  13. import ly_test_tools.environment.waiter as waiter
  14. logger = logging.getLogger(__name__)
  15. _PROCESS_OUTPUT_ENCODING = 'utf-8'
  16. # Default list of processes names to kill
  17. LY_PROCESS_KILL_LIST = [
  18. 'AssetBuilder', 'AssetProcessor', 'AssetProcessorBatch',
  19. 'CrySCompileServer', 'Editor',
  20. 'Profiler', 'RemoteConsole',
  21. 'rc' # Resource Compiler
  22. ]
  23. def kill_processes_named(names, ignore_extensions=False):
  24. """
  25. Kills all processes with a given name
  26. :param names: string process name, or list of strings of process name
  27. :param ignore_extensions: ignore trailing file extensions. By default 'abc.exe' will not match 'abc'. Note that
  28. enabling this will cause 'abc.exe' to match 'abc', 'abc.bat', and 'abc.sh', though 'abc.GameLauncher.exe'
  29. will not match 'abc.DedicatedServer'
  30. """
  31. if not names:
  32. return
  33. name_set = set()
  34. if isinstance(names, str):
  35. name_set.add(names)
  36. else:
  37. name_set.update(names)
  38. if ignore_extensions:
  39. # both exact matches and extensionless
  40. stripped_names = set()
  41. for name in name_set:
  42. stripped_names.add(_remove_extension(name))
  43. name_set.update(stripped_names)
  44. # remove any blank names, which may empty the list
  45. name_set = set(filter(lambda x: not x.isspace(), name_set))
  46. if not name_set:
  47. return
  48. logger.info(f"Killing all processes named {name_set}")
  49. process_set_to_kill = set()
  50. for process in _safe_get_processes(['name', 'pid']):
  51. try:
  52. proc_name = process.name()
  53. except psutil.AccessDenied:
  54. logger.warning(f"Process {process} permissions error during kill_processes_named()", exc_info=True)
  55. continue
  56. except psutil.ProcessLookupError:
  57. logger.debug(f"Process {process} could not be killed during kill_processes_named() and was likely already "
  58. f"stopped", exc_info=True)
  59. continue
  60. except psutil.NoSuchProcess:
  61. logger.debug(f"Process '{process}' was active when list of processes was requested but it was not found "
  62. f"during kill_processes_named()", exc_info=True)
  63. continue
  64. if proc_name in name_set:
  65. logger.debug(f"Found process with name {proc_name}.")
  66. process_set_to_kill.add(process)
  67. if ignore_extensions:
  68. extensionless_name = _remove_extension(proc_name)
  69. if extensionless_name in name_set:
  70. process_set_to_kill.add(process)
  71. if process_set_to_kill:
  72. _safe_kill_processes(process_set_to_kill)
  73. def kill_processes_started_from(path):
  74. """
  75. Kills all processes started from a given directory or executable
  76. :param path: path to application or directory
  77. """
  78. logger.info(f"Killing processes started from '{path}'")
  79. if os.path.exists(path):
  80. process_list = []
  81. for process in _safe_get_processes():
  82. try:
  83. process_path = process.exe()
  84. except (psutil.AccessDenied, psutil.NoSuchProcess):
  85. continue
  86. if process_path.lower().startswith(path.lower()):
  87. process_list.append(process)
  88. _safe_kill_processes(process_list)
  89. else:
  90. logger.warning(f"Path:'{path}' not found")
  91. def kill_processes_with_name_not_started_from(name, path):
  92. """
  93. Kills all processes with a given name that NOT started from a directory or executable
  94. :param name: name of application to look for
  95. :param path: path where process shouldn't have started from
  96. """
  97. path = os.path.join(os.getcwd(), os.path.normpath(path)).lower()
  98. logger.info(f"Killing processes with name:'{name}' not started from '{path}'")
  99. if os.path.exists(path):
  100. proccesses_to_kill = []
  101. for process in _safe_get_processes(["name", "pid"]):
  102. try:
  103. process_path = process.exe()
  104. except (psutil.AccessDenied, psutil.NoSuchProcess) as ex:
  105. continue
  106. process_name = os.path.splitext(os.path.basename(process_path))[0]
  107. if process_name == os.path.basename(name) and not os.path.dirname(process_path.lower()) == path:
  108. logger.info("%s -> %s" % (os.path.dirname(process_path.lower()), path))
  109. proccesses_to_kill.append(process)
  110. _safe_kill_processes(proccesses_to_kill)
  111. else:
  112. logger.warning(f"Path:'{path}' not found")
  113. def kill_process_with_pid(pid, raise_on_missing=False):
  114. """
  115. Kills the process with the specified pid
  116. :param pid: the pid of the process to kill
  117. :param raise_on_missing: if set to True, raise RuntimeError if the process does not already exist
  118. """
  119. if pid is None:
  120. logger.warning("Killing process id of 'None' will terminate the current python process!")
  121. logger.info(f"Killing processes with id '{pid}'")
  122. process = psutil.Process(pid)
  123. if process.is_running():
  124. _safe_kill_process(process)
  125. elif raise_on_missing:
  126. message = f"Process with id {pid} was not present"
  127. logger.error(message)
  128. raise RuntimeError(message)
  129. def process_exists(name, ignore_extensions=False):
  130. """
  131. Determines whether a process with the given name exists
  132. :param name: process name
  133. :param ignore_extensions: ignore trailing file extension
  134. :return: A boolean determining whether the process is alive or not
  135. """
  136. name = name.lower()
  137. if name.isspace():
  138. return False
  139. if ignore_extensions:
  140. name_extensionless = _remove_extension(name)
  141. for process in _safe_get_processes(["name"]):
  142. try:
  143. proc_name = process.name().lower()
  144. except psutil.NoSuchProcess as e:
  145. logger.debug(f"Process '{process}' was active when list of processes was requested but it was not found "
  146. f"during process_exists()", exc_info=True)
  147. continue
  148. except psutil.AccessDenied as e:
  149. logger.warning(f"Permissions issue on {process} during process_exists check", exc_info=True)
  150. continue
  151. if proc_name == name: # abc.exe matches abc.exe
  152. return True
  153. if ignore_extensions:
  154. proc_name_extensionless = _remove_extension(proc_name)
  155. if proc_name_extensionless == name: # abc matches abc.exe
  156. return True
  157. if proc_name == name_extensionless: # abc.exe matches abc
  158. return True
  159. # don't check proc_name_extensionless against name_extensionless: abc.exe and abc.exe are already tested,
  160. # however xyz.Gamelauncher should not match xyz.DedicatedServer
  161. return False
  162. def process_is_unresponsive(name):
  163. """
  164. Check if the specified process is unresponsive.
  165. Mac warning: this method assumes that a process is not responsive if it is sleeping or waiting, this is true for
  166. 'active' applications, but may not be the case for power optimized applications.
  167. :param name: the name of the process to check
  168. :return: True if the specified process is unresponsive and False otherwise
  169. """
  170. if ly_test_tools.WINDOWS:
  171. output = check_output(['tasklist',
  172. '/FI', f'IMAGENAME eq {name}',
  173. '/FI', 'STATUS eq NOT RESPONDING'])
  174. output = output.split(os.linesep)
  175. for line in output:
  176. if line and name.startswith(line.split()[0]):
  177. logger.debug(f"Process '{name}' was unresponsive.")
  178. logger.debug(line)
  179. return True
  180. logger.debug(f"Process '{name}' was not unresponsive.")
  181. return False
  182. else:
  183. cmd = ["ps", "-axc", "-o", "command,state"]
  184. output = check_output(cmd)
  185. for line in output.splitlines()[1:]:
  186. info = [l.strip() for l in line.split(" ") if l.strip() != '']
  187. state = info[-1]
  188. pname = " ".join(info[0:-1])
  189. if pname == name:
  190. logger.debug(f"{pname}: {state}")
  191. if "R" not in state:
  192. logger.debug(f"Process {name} was unresponsive.")
  193. return True
  194. logger.debug(f"Process '{name}' was not unresponsive.")
  195. return False
  196. def check_output(command, **kwargs):
  197. """
  198. Forwards arguments to subprocess.check_output so better error messages can be displayed upon failure.
  199. If you need the stderr output from a failed process then pass in stderr=subprocess.STDOUT as a kwarg.
  200. :param command: A list of the command to execute and its arguments as split by whitespace.
  201. :param kwargs: Keyword args forwarded to subprocess.check_output.
  202. :return: Output from the command if it succeeds.
  203. """
  204. cmd_string = command
  205. if type(command) == list:
  206. cmd_string = ' '.join(command)
  207. logger.info(f'Executing "check_output({cmd_string})"')
  208. try:
  209. output = subprocess.check_output(command, **kwargs).decode(_PROCESS_OUTPUT_ENCODING)
  210. except subprocess.CalledProcessError as e:
  211. logger.error(f'Command "{cmd_string}" failed with returncode {e.returncode}, output:\n{e.output}')
  212. raise
  213. logger.info(f'Successfully executed "check_output({cmd_string})"')
  214. return output
  215. def safe_check_output(command, **kwargs):
  216. """
  217. Forwards arguments to subprocess.check_output so better error messages can be displayed upon failure.
  218. This function eats the subprocess.CalledProcessError exception upon command failure and returns the output.
  219. If you need the stderr output from a failed process then pass in stderr=subprocess.STDOUT as a kwarg.
  220. :param command: A list of the command to execute and its arguments as split by whitespace.
  221. :param kwargs: Keyword args forwarded to subprocess.check_output.
  222. :return: Output from the command regardless of its return value.
  223. """
  224. cmd_string = command
  225. if type(command) == list:
  226. cmd_string = ' '.join(command)
  227. logger.info(f'Executing "check_output({cmd_string})"')
  228. try:
  229. output = subprocess.check_output(command, **kwargs).decode(_PROCESS_OUTPUT_ENCODING)
  230. except subprocess.CalledProcessError as e:
  231. output = e.output
  232. logger.warning(f'Command "{cmd_string}" failed with returncode {e.returncode}, output:\n{e.output}')
  233. else:
  234. logger.info(f'Successfully executed "check_output({cmd_string})"')
  235. return output
  236. def check_call(command, **kwargs):
  237. """
  238. Forwards arguments to subprocess.check_call so better error messages can be displayed upon failure.
  239. :param command: A list of the command to execute and its arguments as if split by whitespace.
  240. :param kwargs: Keyword args forwarded to subprocess.check_call.
  241. :return: An exitcode of 0 if the call succeeds.
  242. """
  243. cmd_string = command
  244. if type(command) == list:
  245. cmd_string = ' '.join(command)
  246. logger.info(f'Executing "check_call({cmd_string})"')
  247. try:
  248. subprocess.check_call(command, **kwargs)
  249. except subprocess.CalledProcessError as e:
  250. logger.error(f'Command "{cmd_string}" failed with returncode {e.returncode}')
  251. raise
  252. logger.info(f'Successfully executed "check_call({cmd_string})"')
  253. return 0
  254. def safe_check_call(command, **kwargs):
  255. """
  256. Forwards arguments to subprocess.check_call so better error messages can be displayed upon failure.
  257. This function eats the subprocess.CalledProcessError exception upon command failure and returns the exit code.
  258. :param command: A list of the command to execute and its arguments as if split by whitespace.
  259. :param kwargs: Keyword args forwarded to subprocess.check_call.
  260. :return: An exitcode of 0 if the call succeeds, otherwise the exitcode returned from the failed subprocess call.
  261. """
  262. cmd_string = command
  263. if type(command) == list:
  264. cmd_string = ' '.join(command)
  265. logger.info(f'Executing "check_call({cmd_string})"')
  266. try:
  267. subprocess.check_call(command, **kwargs)
  268. except subprocess.CalledProcessError as e:
  269. logger.warning(f'Command "{cmd_string}" failed with returncode {e.returncode}')
  270. return e.returncode
  271. else:
  272. logger.info(f'Successfully executed "check_call({cmd_string})"')
  273. return 0
  274. def _safe_get_processes(attrs=None):
  275. """
  276. Returns the process iterator without raising an error if the process list changes
  277. :return: The process iterator
  278. """
  279. processes = None
  280. max_attempts = 10
  281. for _ in range(max_attempts):
  282. try:
  283. processes = psutil.process_iter(attrs)
  284. break
  285. except (psutil.Error, RuntimeError):
  286. logger.debug("Unexpected error", exc_info=True)
  287. continue
  288. return processes
  289. def _safe_kill_process(proc):
  290. """
  291. Kills a given process without raising an error
  292. :param proc: The process to kill
  293. """
  294. try:
  295. logger.info(f"Terminating process '{proc.name()}' with id '{proc.pid}'")
  296. _terminate_and_confirm_dead(proc)
  297. except psutil.AccessDenied:
  298. logger.warning("Termination failed, Access Denied", exc_info=True)
  299. except psutil.NoSuchProcess:
  300. logger.debug("Termination request ignored, process was already terminated during iteration", exc_info=True)
  301. except Exception: # purposefully broad
  302. logger.warning("Unexpected exception while terminating process", exc_info=True)
  303. def _safe_kill_processes(processes):
  304. """
  305. Kills a given process without raising an error
  306. :param processes: An iterable of processes to kill
  307. """
  308. for proc in processes:
  309. try:
  310. logger.info(f"Terminating process '{proc.name()}' with id '{proc.pid}'")
  311. proc.kill()
  312. except psutil.AccessDenied:
  313. logger.warning("Termination failed, Access Denied with stacktrace:", exc_info=True)
  314. except psutil.NoSuchProcess:
  315. logger.debug("Termination request ignored, process was already terminated during iteration with stacktrace:", exc_info=True)
  316. except Exception: # purposefully broad
  317. logger.debug("Unexpected exception ignored while terminating process, with stacktrace:", exc_info=True)
  318. def on_terminate(proc):
  319. try:
  320. logger.info(f"process '{proc.name()}' with id '{proc.pid}' terminated with exit code {proc.returncode}")
  321. except psutil.AccessDenied:
  322. logger.warning("Termination failed, Access Denied with stacktrace:", exc_info=True)
  323. except psutil.NoSuchProcess:
  324. logger.debug("Termination request ignored, process was already terminated during iteration with stacktrace:", exc_info=True)
  325. try:
  326. psutil.wait_procs(processes, timeout=30, callback=on_terminate)
  327. except psutil.AccessDenied:
  328. logger.warning("Termination failed, Access Denied with stacktrace:", exc_info=True)
  329. except psutil.NoSuchProcess:
  330. logger.debug("Termination request ignored, process was already terminated during iteration with stacktrace:", exc_info=True)
  331. except Exception: # purposefully broad
  332. logger.debug("Unexpected exception while waiting for processes to terminate, with stacktrace:", exc_info=True)
  333. def _terminate_and_confirm_dead(proc):
  334. """
  335. Kills a process and waits for the process to stop running.
  336. :param proc: A process to kill, and wait for proper termination
  337. """
  338. def killed():
  339. return not proc.is_running()
  340. proc.kill()
  341. waiter.wait_for(killed, exc=RuntimeError("Process did not terminate after kill command"))
  342. def _remove_extension(filename):
  343. """
  344. Returns a file name without its extension, if any is present
  345. :param filename: The name of a file
  346. :return: The name of the file without the extension
  347. """
  348. return filename.rsplit(".", 1)[0]
  349. def close_windows_process(pid, timeout=20, raise_on_missing=False):
  350. # type: (int, int, bool) -> None
  351. """
  352. Closes a window using the windows api and checks the return code. An error will be raised if the window hasn't
  353. closed after the timeout duration.
  354. Note: This is for Windows only and will fail on any other OS
  355. :param pid: The process id of the process window to close
  356. :param timeout: How long to wait for the window to close (seconds)
  357. :return: None
  358. :param pid: the pid of the process to kill
  359. :param raise_on_missing: if set to True, raise RuntimeError if the process does not already exist
  360. """
  361. if not ly_test_tools.WINDOWS:
  362. raise NotImplementedError("close_windows_process() is only implemented on Windows.")
  363. if pid is None:
  364. raise TypeError("Cannot close window with pid of None")
  365. if not psutil.Process(pid).is_running():
  366. if raise_on_missing:
  367. message = f"Process with id {pid} was unexpectedly not present"
  368. logger.error(message)
  369. raise RuntimeError(message)
  370. else:
  371. logger.warning(f"Process with id {pid} was not present but option raise_on_missing is disabled. Unless "
  372. f"a matching process gets opened, calling close_windows_process will spin until its timeout")
  373. # Gain access to windows api
  374. user32 = ctypes.windll.user32
  375. # Set up C data types and function params
  376. WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.wintypes.BOOL,
  377. ctypes.wintypes.HWND,
  378. ctypes.wintypes.LPARAM)
  379. user32.EnumWindows.argtypes = [
  380. WNDENUMPROC,
  381. ctypes.wintypes.LPARAM]
  382. user32.GetWindowTextLengthW.argtypes = [
  383. ctypes.wintypes.HWND]
  384. # This is called for each process window
  385. def _close_matched_process_window(hwnd, _):
  386. # type: (ctypes.wintypes.HWND, int) -> bool
  387. """
  388. EnumWindows() takes a function argument that will return True/False to keep iterating or not.
  389. Checks the windows handle's pid against the given pid. If they match, then the window will be closed and
  390. returns False.
  391. :param hwnd: A windows handle to check against the pid
  392. :param _: Unused buffer parameter
  393. :return: False if process was found and closed, else True
  394. """
  395. # Get the process id of the handle
  396. lpdw_process_id = ctypes.c_ulong()
  397. user32.GetWindowThreadProcessId(hwnd, ctypes.byref(lpdw_process_id))
  398. process_id = lpdw_process_id.value
  399. # Compare to the process id
  400. if pid == process_id:
  401. # Close the window
  402. WM_CLOSE = 16 # System message for closing window: 0x10
  403. user32.PostMessageA(hwnd, WM_CLOSE, 0, 0)
  404. # Found window for process id, stop iterating
  405. return False
  406. # Process not found, keep looping
  407. return True
  408. # Call the function on all of the handles
  409. close_process_func = WNDENUMPROC(_close_matched_process_window)
  410. user32.EnumWindows(close_process_func, 0)
  411. # Wait for asyncronous termination
  412. waiter.wait_for(lambda: pid not in psutil.pids(), timeout=timeout,
  413. exc=TimeoutError(f"Process {pid} never terminated"))
  414. def get_display_env():
  415. """
  416. Fetches environment variables with an appropriate display (monitor) configured,
  417. useful for subprocess calls to UI applications
  418. :return: A dictionary containing environment variables (per os.environ)
  419. """
  420. env = os.environ.copy()
  421. if not ly_test_tools.WINDOWS:
  422. if 'DISPLAY' not in env.keys():
  423. # assume Display 1 is available in another session
  424. env['DISPLAY'] = ':1'
  425. return env