mirror of
https://github.com/lutris/lutris
synced 2024-10-02 22:14:23 +00:00
speed up detection of wine process exit
This changeset installs a `SIGCHLD` signal handler which reaps child processes, and on successful child process reaping, triggers an early execution of `watch_children` to evaluate the new process state. This also removes the cycle count requirement for marking a process as exited since we now have more reliable ways to determine the state of the application. It also adds more background wine processes to the exclude list by default. Combined, these changes mean much more rapid detection of an exited wine game. This should significantly help #1012.
This commit is contained in:
parent
4a36f3b33a
commit
64a24405f9
|
@ -1022,8 +1022,6 @@ Sysoptions
|
|||
|
||||
``disable_runtime`` (example: ``true``)
|
||||
|
||||
``monitor_max_cycles`` adjusts the number of cycles the monitor will wait until declaring a game as exited (example: 10)
|
||||
|
||||
``disable_compositor`` (example: ``true``)
|
||||
|
||||
``reset_pulse`` (example: ``true``)
|
||||
|
|
|
@ -443,8 +443,6 @@ class Game(GObject.Object):
|
|||
if system_config.get("disable_compositor"):
|
||||
self.set_desktop_compositing(False)
|
||||
|
||||
monitor_max_cycles = int(system_config.get("monitor_max_cycles")) or 5
|
||||
|
||||
prelaunch_command = self.runner.system_config.get("prelaunch_command")
|
||||
if system.path_exists(prelaunch_command):
|
||||
self.prelaunch_thread = MonitoredCommand(
|
||||
|
@ -464,7 +462,6 @@ class Game(GObject.Object):
|
|||
log_buffer=self.log_buffer,
|
||||
include_processes=include_processes,
|
||||
exclude_processes=exclude_processes,
|
||||
max_cycles=monitor_max_cycles
|
||||
)
|
||||
if hasattr(self.runner, "stop"):
|
||||
self.game_thread.stop_func = self.runner.stop
|
||||
|
|
|
@ -269,18 +269,6 @@ system_options = [ # pylint: disable=invalid-name
|
|||
"can be wrapped in quotation marks."
|
||||
),
|
||||
},
|
||||
{
|
||||
"option": "monitor_max_cycles",
|
||||
"type": "string",
|
||||
"label": "Maximum number of empty monitor cycles",
|
||||
"advanced": True,
|
||||
"default": "5",
|
||||
"help": (
|
||||
"Number of cycles to wait before the game is considered as exited. "
|
||||
"Each cycle is 2 seconds. Increasing the value will increase the "
|
||||
"time Lutris take to update the exit status."
|
||||
)
|
||||
},
|
||||
{
|
||||
"option": "single_cpu",
|
||||
"type": "bool",
|
||||
|
|
|
@ -5,6 +5,9 @@ import sys
|
|||
import shlex
|
||||
import subprocess
|
||||
import contextlib
|
||||
import signal
|
||||
import weakref
|
||||
import functools
|
||||
from textwrap import dedent
|
||||
|
||||
from gi.repository import GLib
|
||||
|
@ -20,6 +23,57 @@ HEARTBEAT_DELAY = 2000 # Number of milliseconds between each heartbeat
|
|||
DEFAULT_MAX_CYCLES = 5
|
||||
|
||||
|
||||
def _reentrancy_guard(func):
|
||||
"""
|
||||
Prevents an argumentless method from having two invocations running
|
||||
at the same time. self must be hashable.
|
||||
"""
|
||||
guards = weakref.WeakSet()
|
||||
@functools.wraps(func)
|
||||
def inner(self):
|
||||
if self not in guards:
|
||||
guards.add(self)
|
||||
try:
|
||||
return func(self)
|
||||
finally:
|
||||
guards.remove(self)
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
#
|
||||
# This setup uses SIGCHLD as a trigger to check on the runner process
|
||||
# in order to detect the monitoredcommand's complete exit early instead
|
||||
# of on the next polling interval. Because processes can be created
|
||||
# and exited very rapidly, it includes a 16 millisecond debounce.
|
||||
#
|
||||
_commands = weakref.WeakSet()
|
||||
_timeout_set = False
|
||||
|
||||
def _trigger_early_poll():
|
||||
global _timeout_set
|
||||
try:
|
||||
# prevent changes to size during iteration
|
||||
for command in set(_commands):
|
||||
command.watch_children()
|
||||
except Exception:
|
||||
logger.exception("Signal handler exception")
|
||||
finally:
|
||||
_timeout_set = False
|
||||
return False
|
||||
|
||||
def _sigchld_handler(signum, frame):
|
||||
global _timeout_set
|
||||
try:
|
||||
os.wait3(os.WNOHANG)
|
||||
except ChildProcessError: # already handled by someone else
|
||||
return
|
||||
if _commands and not _timeout_set:
|
||||
GLib.timeout_add(16, _trigger_early_poll)
|
||||
_timeout_set = True
|
||||
|
||||
signal.signal(signal.SIGCHLD, _sigchld_handler)
|
||||
|
||||
class MonitoredCommand:
|
||||
"""Run the game."""
|
||||
|
||||
|
@ -36,7 +90,6 @@ class MonitoredCommand:
|
|||
include_processes=None,
|
||||
exclude_processes=None,
|
||||
log_buffer=None,
|
||||
max_cycles=DEFAULT_MAX_CYCLES
|
||||
):
|
||||
self.ready_state = True
|
||||
if env is None:
|
||||
|
@ -57,16 +110,11 @@ class MonitoredCommand:
|
|||
self.error = None
|
||||
self.log_buffer = log_buffer
|
||||
self.stdout_monitor = None
|
||||
self.watch_children_running = False
|
||||
|
||||
# Keep a copy of previously running processes
|
||||
self.cwd = self.get_cwd(cwd)
|
||||
if max_cycles < 0:
|
||||
logger.warning("Invalid value for maximum number of cycles: %s, using default: %s",
|
||||
max_cycles,
|
||||
DEFAULT_MAX_CYCLES)
|
||||
max_cycles = DEFAULT_MAX_CYCLES
|
||||
self.process_monitor = ProcessMonitor(
|
||||
max_cycles,
|
||||
include_processes,
|
||||
exclude_processes,
|
||||
"run_in_term.sh" if self.terminal else None
|
||||
|
@ -111,6 +159,7 @@ class MonitoredCommand:
|
|||
logger.warning("No game process available")
|
||||
return
|
||||
|
||||
_commands.add(self)
|
||||
if self.watch:
|
||||
GLib.timeout_add(HEARTBEAT_DELAY, self.watch_children)
|
||||
self.stdout_monitor = GLib.io_add_watch(
|
||||
|
@ -202,6 +251,11 @@ class MonitoredCommand:
|
|||
|
||||
def stop(self):
|
||||
"""Stops the current game process and cleans up the thread"""
|
||||
try:
|
||||
_commands.remove(self)
|
||||
except KeyError: # may have never been added.
|
||||
pass
|
||||
|
||||
# Remove logger early to avoid issues with zombie processes
|
||||
# (unconfirmed)
|
||||
if self.stdout_monitor:
|
||||
|
@ -231,6 +285,7 @@ class MonitoredCommand:
|
|||
process.children.append(wineprocess)
|
||||
return process
|
||||
|
||||
@_reentrancy_guard
|
||||
def watch_children(self):
|
||||
"""Poke at the running process(es).
|
||||
|
||||
|
|
|
@ -33,6 +33,10 @@ EXCLUDED_PROCESSES = [
|
|||
"PnkBstrA.exe",
|
||||
"control",
|
||||
"wineserver",
|
||||
"services.exe",
|
||||
"winedevice.exe",
|
||||
"plugplay.exe",
|
||||
"explorer.exe",
|
||||
"winecfg.exe",
|
||||
"wdfmgr.exe",
|
||||
"wineconsole",
|
||||
|
@ -73,14 +77,12 @@ def set_child_subreaper():
|
|||
class ProcessMonitor:
|
||||
"""Class to keep track of a process and its children status"""
|
||||
|
||||
def __init__(self, max_cycles, include_processes, exclude_processes, exclusion_process):
|
||||
def __init__(self, include_processes, exclude_processes, exclusion_process):
|
||||
"""Creates a process monitor
|
||||
|
||||
All arguments accept process names like the ones in EXCLUDED_PROCESSES
|
||||
|
||||
Args:
|
||||
max_cycles (int): number of cycles without children the monitor
|
||||
should wait before considering the game dead
|
||||
exclude_processes (str or list): list of processes that shouldn't be monitored
|
||||
include_processes (str or list): list of process that should be forced to be monitored
|
||||
exclusion_process (str): If given, ignore all process before this one
|
||||
|
@ -94,8 +96,6 @@ class ProcessMonitor:
|
|||
x[0:15] for x in EXCLUDED_PROCESSES + self.parse_process_list(exclude_processes)
|
||||
]
|
||||
self.exclusion_process = exclusion_process
|
||||
self.cycles_without_children = 0
|
||||
self.max_cycles = int(max_cycles)
|
||||
self.old_pids = system.get_all_pids()
|
||||
# Keep a copy of the monitored processes to allow comparisons
|
||||
self.monitored_processes = defaultdict(list)
|
||||
|
@ -116,8 +116,7 @@ class ProcessMonitor:
|
|||
for child in process.children:
|
||||
if topdown:
|
||||
yield child
|
||||
for granchild in self.iter_children(child, topdown=topdown):
|
||||
yield granchild
|
||||
yield from self.iter_children(child, topdown=topdown)
|
||||
if not topdown:
|
||||
yield child
|
||||
|
||||
|
@ -128,8 +127,12 @@ class ProcessMonitor:
|
|||
has_passed_exclusion_process = False
|
||||
processes = defaultdict(list)
|
||||
for child in self.iter_children(process):
|
||||
if child.state == 'Z':
|
||||
os.wait3(os.WNOHANG)
|
||||
if child.state == 'Z': # should never happen anymore...
|
||||
logger.debug("Unexpected zombie process %s", child)
|
||||
try:
|
||||
os.wait3(os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
pass
|
||||
continue
|
||||
|
||||
if self.exclusion_process:
|
||||
|
@ -152,6 +155,7 @@ class ProcessMonitor:
|
|||
processes["excluded"].append(str(child))
|
||||
continue
|
||||
num_watched_children += 1
|
||||
|
||||
for child in self.monitored_processes["monitored"]:
|
||||
if child not in processes["monitored"]:
|
||||
self.children.append(child)
|
||||
|
@ -167,10 +171,8 @@ class ProcessMonitor:
|
|||
if num_watched_children > 0 and not self.monitoring_started:
|
||||
logger.debug("Start process monitoring")
|
||||
self.monitoring_started = True
|
||||
|
||||
if num_watched_children == 0 and self.monitoring_started:
|
||||
self.cycles_without_children += 1
|
||||
if self.cycles_without_children >= self.max_cycles:
|
||||
logger.info("Monitor detected no activity on the process")
|
||||
return False
|
||||
return False
|
||||
|
||||
return True
|
||||
|
|
Loading…
Reference in a new issue