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:
Aaron Opfer 2018-12-19 03:53:10 -06:00 committed by Mathieu Comandon
parent 4a36f3b33a
commit 64a24405f9
5 changed files with 77 additions and 37 deletions

View file

@ -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``)

View file

@ -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

View file

@ -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",

View file

@ -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).

View file

@ -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