From 1ca604ec7896451045182a90f4e512802cc6218f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordan=20Bri=C3=A8re?= Date: Tue, 1 Aug 2023 16:52:21 -0400 Subject: [PATCH] Threads updates. Implemented workaround for threads starvation issues. Added new threads module that provides various utilities. Added core.get_calling_plugin and core.autounload_disabled. Made bitbuf messages thread-safe. Fixed CachedProperty not properly wrapping its getter's docstring. Added server_game_dll.is_hibernating. Added OnServerHibernating and OnServerWakingUp listeners. --- .../developing/module_tutorials/listeners.rst | 28 + .../source/developing/modules/threads.rst | 7 + .../source/general/known-issues.rst | 7 +- .../packages/source-python/__init__.py | 24 + .../source-python/auth/backends/sql.py | 4 +- .../packages/source-python/core/__init__.py | 85 ++- .../packages/source-python/core/settings.py | 17 + .../source-python/listeners/__init__.py | 26 +- .../packages/source-python/listeners/tick.py | 24 +- .../packages/source-python/messages/base.py | 13 +- .../packages/source-python/threads.py | 571 ++++++++++++++++++ .../_core/core_settings_strings.ini | 6 + src/CMakeLists.txt | 16 + src/core/modules/core/core_cache.cpp | 9 +- src/core/modules/engines/engines.h | 25 + .../modules/engines/engines_server_wrap.cpp | 7 + src/core/modules/threads/blade/threads.h | 38 ++ src/core/modules/threads/bms/threads.h | 38 ++ src/core/modules/threads/csgo/threads.h | 53 ++ src/core/modules/threads/gmod/threads.h | 38 ++ src/core/modules/threads/l4d2/threads.h | 38 ++ src/core/modules/threads/orangebox/threads.h | 52 ++ src/core/modules/threads/threads.cpp | 127 ++++ src/core/modules/threads/threads.h | 53 ++ src/core/modules/threads/threads_wrap.cpp | 83 +++ 25 files changed, 1336 insertions(+), 53 deletions(-) create mode 100644 addons/source-python/docs/source-python/source/developing/modules/threads.rst create mode 100644 addons/source-python/packages/source-python/threads.py create mode 100644 src/core/modules/threads/blade/threads.h create mode 100644 src/core/modules/threads/bms/threads.h create mode 100644 src/core/modules/threads/csgo/threads.h create mode 100644 src/core/modules/threads/gmod/threads.h create mode 100644 src/core/modules/threads/l4d2/threads.h create mode 100644 src/core/modules/threads/orangebox/threads.h create mode 100644 src/core/modules/threads/threads.cpp create mode 100644 src/core/modules/threads/threads.h create mode 100644 src/core/modules/threads/threads_wrap.cpp diff --git a/addons/source-python/docs/source-python/source/developing/module_tutorials/listeners.rst b/addons/source-python/docs/source-python/source/developing/module_tutorials/listeners.rst index 00ba84107..daf562598 100644 --- a/addons/source-python/docs/source-python/source/developing/module_tutorials/listeners.rst +++ b/addons/source-python/docs/source-python/source/developing/module_tutorials/listeners.rst @@ -633,6 +633,34 @@ message is logged/printed or not. return OutputReturn.CONTINUE +OnServerHibernating +------------------- + +Called when the server starts hibernating. + +.. code-block:: python + + from listeners import OnServerHibernating + + @OnServerHibernating + def on_server_hibernating(): + ... + + +OnServerWakingUp +---------------- + +Called when the server is waking up from hibernation. + +.. code-block:: python + + from listeners import OnServerWakingUp + + @OnServerWakingUp + def on_server_waking_up(): + ... + + OnTick ------ diff --git a/addons/source-python/docs/source-python/source/developing/modules/threads.rst b/addons/source-python/docs/source-python/source/developing/modules/threads.rst new file mode 100644 index 000000000..40f0a0dc9 --- /dev/null +++ b/addons/source-python/docs/source-python/source/developing/modules/threads.rst @@ -0,0 +1,7 @@ +threads module +============== + +.. automodule:: threads + :members: + :undoc-members: + :show-inheritance: diff --git a/addons/source-python/docs/source-python/source/general/known-issues.rst b/addons/source-python/docs/source-python/source/general/known-issues.rst index d4e085b90..debd26e04 100644 --- a/addons/source-python/docs/source-python/source/general/known-issues.rst +++ b/addons/source-python/docs/source-python/source/general/known-issues.rst @@ -11,7 +11,12 @@ which causes either EventScripts to be loaded with Source.Python's Python version or vice versa. This doesn't work well and results in a crash on startup. SourceMod's Accelerator incompatibility ---------------------------------------------------- +--------------------------------------- If you are running `SourceMod's Accelerator `_ with Source.Python, you may experience random crashes that would normally be caught since this extension prevents us from catching and preventing them. + +Hibernation issues +------------------ +Some features (such as tick listeners, delays, Python threads, etc.) do not work on some games (e.g. CS:GO) +while the server is hibernating. If you require these features at all time, please disable hibernation. diff --git a/addons/source-python/packages/source-python/__init__.py b/addons/source-python/packages/source-python/__init__.py index 5ca9588e5..f8ceac051 100644 --- a/addons/source-python/packages/source-python/__init__.py +++ b/addons/source-python/packages/source-python/__init__.py @@ -92,6 +92,7 @@ def load(): setup_entities_listener() setup_versioning() setup_sqlite() + setup_threads() def unload(): @@ -534,3 +535,26 @@ def flush(self): 'Source.Python should continue working, but we would like to figure ' 'out in which situations sys.stdout is None to be able to fix this ' 'issue instead of applying a workaround.') + + +# ============================================================================= +# >> THREADS +# ============================================================================= +def setup_threads(): + """Setup threads.""" + import listeners.tick + from threads import GameThread + listeners.tick.GameThread = GameThread # Backward compatibility + + from threads import ThreadYielder + if not ThreadYielder.is_implemented(): + return + + from core.settings import _core_settings + from threads import sp_thread_yielding + + sp_thread_yielding.set_string( + _core_settings.get( + 'THREAD_SETTINGS', {} + ).get('enable_thread_yielding', '0'), + ) diff --git a/addons/source-python/packages/source-python/auth/backends/sql.py b/addons/source-python/packages/source-python/auth/backends/sql.py index 0bead183c..425dfa187 100644 --- a/addons/source-python/packages/source-python/auth/backends/sql.py +++ b/addons/source-python/packages/source-python/auth/backends/sql.py @@ -18,8 +18,8 @@ from auth.manager import ParentPermissions # Paths from paths import SP_DATA_PATH -# Listeners -from listeners.tick import GameThread +# Threads +from threads import GameThread # Site-Packges Imports # SQL Alechemy diff --git a/addons/source-python/packages/source-python/core/__init__.py b/addons/source-python/packages/source-python/core/__init__.py index 5b8a67e07..01af88f6d 100755 --- a/addons/source-python/packages/source-python/core/__init__.py +++ b/addons/source-python/packages/source-python/core/__init__.py @@ -74,10 +74,12 @@ 'SOURCE_ENGINE', 'SOURCE_ENGINE_BRANCH', 'Tokenize', + 'autounload_disabled', 'check_info_output', 'console_message', 'create_checksum', 'echo_console', + 'get_calling_plugin', 'get_core_modules', 'get_interface', 'get_public_ip', @@ -95,6 +97,9 @@ # Get the platform the server is on PLATFORM = system().lower() +# Whether auto unload classes are disabled +_autounload_disabled = False + # ============================================================================= # >> CLASSES @@ -113,29 +118,12 @@ def __new__(cls, *args, **kwargs): # Get the class instance self = super().__new__(cls) - # Get the calling frame - frame = currentframe().f_back - - # Get the calling path - path = frame.f_code.co_filename - - # Don't keep hostage instances that will never be unloaded - while not path.startswith(PLUGIN_PATH): - frame = frame.f_back - if frame is None: - return self - path = frame.f_code.co_filename - if path.startswith('> FUNCTIONS # ============================================================================= +@contextmanager +def autounload_disabled(): + """Context that disables auto unload classes.""" + global _autounload_disabled + prev = _autounload_disabled + _autounload_disabled = True + try: + yield + finally: + _autounload_disabled = prev + + +def get_calling_plugin(depth=0): + """Resolves the name of the calling plugin. + + :param int depth: + How many frame back to start looking for a plugin. + + :rtype: + str + """ + # Get the current frame + frame = currentframe() + + # Go back the specificed depth + for _ in range(depth + 1): + frame = frame.f_back + + # Get the calling path + path = frame.f_code.co_filename + + # Don't keep hostage instances that will never be unloaded + while not path.startswith(PLUGIN_PATH): + frame = frame.f_back + if frame is None: + return + path = frame.f_code.co_filename + if path.startswith('> FUNCTIONS # ============================================================================= @@ -697,7 +715,13 @@ def _pre_fire_output(args): @PreHook(get_virtual_function(server_game_dll, _hibernation_function_name)) def _pre_hibernation_function(stack_data): """Called when the server is hibernating.""" - if not stack_data[1]: + hibernating = stack_data[1] + if hibernating: + on_server_hibernating_listener_manager.notify() + else: + on_server_waking_up_listener_manager.notify() + + if not hibernating: return # Disconnect all bots... diff --git a/addons/source-python/packages/source-python/listeners/tick.py b/addons/source-python/packages/source-python/listeners/tick.py index 333fef15e..c8b37043a 100644 --- a/addons/source-python/packages/source-python/listeners/tick.py +++ b/addons/source-python/packages/source-python/listeners/tick.py @@ -11,8 +11,6 @@ import time from enum import IntEnum -from threading import Thread -from warnings import warn # Source.Python from core import AutoUnload @@ -28,7 +26,7 @@ # ============================================================================= __all__ = ( 'Delay', - 'GameThread', + 'GameThread', # Backward compatibility 'Repeat', 'RepeatStatus', ) @@ -41,26 +39,6 @@ listeners_tick_logger = listeners_logger.tick -# ============================================================================= -# >> THREAD WORKAROUND -# ============================================================================= -class GameThread(WeakAutoUnload, Thread): - """A subclass of :class:`threading.Thread` that throws a warning if the - plugin that created the thread has been unloaded while the thread is still - running. - """ - - def _add_instance(self, caller): - super()._add_instance(caller) - self._caller = caller - - def _unload_instance(self): - if self.is_alive(): - warn( - f'Thread "{self.name}" ({self.ident}) from "{self._caller}" ' - f'is running even though its plugin has been unloaded!') - - # ============================================================================= # >> DELAY CLASSES # ============================================================================= diff --git a/addons/source-python/packages/source-python/messages/base.py b/addons/source-python/packages/source-python/messages/base.py index d4b02284a..d82812994 100755 --- a/addons/source-python/packages/source-python/messages/base.py +++ b/addons/source-python/packages/source-python/messages/base.py @@ -7,6 +7,8 @@ # ============================================================================ # Python Imports import collections +# Threading +from threading import Lock # Source.Python Imports # Colors @@ -78,6 +80,10 @@ def send(self, *player_indexes, **tokens): self._get_translated_kwargs(language, tokens)) self._send(indexes, translated_kwargs) + # Get a lock to ensure thread-safety for bitbuf messages + if not UserMessage.is_protobuf(): + _bitbuf_lock = Lock() + def _send(self, player_indexes, translated_kwargs): """Send the user message to the given players. @@ -87,12 +93,14 @@ def _send(self, player_indexes, translated_kwargs): """ recipients = RecipientFilter(*player_indexes) recipients.reliable = self.reliable - user_message = UserMessage(recipients, self.message_name) - if user_message.is_protobuf(): + if UserMessage.is_protobuf(): + user_message = UserMessage(recipients, self.message_name) self.protobuf(user_message.buffer, translated_kwargs) user_message.send() else: + self._bitbuf_lock.acquire() + user_message = UserMessage(recipients, self.message_name) try: self.bitbuf(user_message.buffer, translated_kwargs) except: @@ -109,6 +117,7 @@ def _send(self, player_indexes, translated_kwargs): raise finally: user_message.send() + self._bitbuf_lock.release() @staticmethod def _categorize_players_by_language(player_indexes): diff --git a/addons/source-python/packages/source-python/threads.py b/addons/source-python/packages/source-python/threads.py new file mode 100644 index 000000000..428b79680 --- /dev/null +++ b/addons/source-python/packages/source-python/threads.py @@ -0,0 +1,571 @@ +# ../threads.py + +"""Provides threads functionality. + +.. data:: sp_thread_yielding + + If enabled, yields remaining cycles to Python threads every frame. + + .. note:: + + Not all games are currently supported. + See also: :func:`ThreadYielder.is_implemented` +""" + +# ============================================================================= +# >> IMPORTS +# ============================================================================= +# Python Imports +# FuncTools +from functools import partial +from functools import wraps +# Threading +from threading import Event +from threading import Thread +from threading import current_thread +from threading import main_thread +# Queue +from queue import Queue +# Sys +from sys import getswitchinterval +# Time +from time import sleep +# Warnings +from warnings import warn + +# Source.Python Imports +# Core +from core import get_calling_plugin +from core import WeakAutoUnload +from core import autounload_disabled +from core.cache import cached_property +# Cvars +from cvars import ConVar +# Engines +from engines.server import global_vars +# Hooks +from hooks.exceptions import except_hooks +# Listeners +from listeners.tick import Delay + + +# ============================================================================= +# >> FORWARD IMPORTS +# ============================================================================= +# Source.Python Imports +# Core +from _threads import ThreadYielder + + +# ============================================================================= +# >> ALL DECLARATION +# ============================================================================= +__all__ = ( + 'GameThread', + 'InfiniteThread', + 'Partial', + 'Queued', + 'ThreadYielder', + 'queued', + 'sp_thread_yielding', + 'threaded', +) + + +# ============================================================================= +# >> GLOBAL VARIABLES +# ============================================================================= +sp_thread_yielding = ConVar('sp_thread_yielding') + + +# ============================================================================= +# >> CLASSES +# ============================================================================= +class GameThread(WeakAutoUnload, Thread): + """A subclass of :class:`threading.Thread` that throws a warning if the + plugin that created the thread has been unloaded while the thread is still + running. + + Example: + + .. code:: python + + from threads import GameThread + + def function(): + ... + + thread = GameThread(function).start() + + .. warning:: + + Multiple active threads or threading routines that are heavy on CPU + can have a huge impact on the networking of the server. + """ + + def __init__(self, target=None, *args, **kwargs): + """Initializes the thread. + + :param callable target: + The target function to execute. + :param tuple *args: + The arguments to pass to ``Thread.__init__``. + :param dict **kwargs: + The keyword arguments to pass to ``Thread.__init__``. + """ + super().__init__(None, target, *args, **kwargs) + + def _add_instance(self, caller): + """Adds the instance and store the caller.""" + super()._add_instance(caller) + self._caller = caller + + def _unload_instance(self): + """Unloads the instance.""" + # Give it at least a frame and a switch to conclude + if self._started.is_set(): + self.join(global_vars.interval_per_tick + getswitchinterval()) + + # Raise awarness if that was not enough time to conclude + if self.is_alive(): + warn( + f'Thread "{self.name}" ({self.ident}) from "{self._caller}" ' + f'is running even though its plugin has been unloaded!') + + @cached_property + def yielder(self): + """Returns the yielder for this thread. + + :rtype: + ThreadYielder + """ + return ThreadYielder() + + def start(self): + """Starts the thread. + + :return: + Return itself so that it can be inlined. + """ + super().start() + return self + + def run(self): + """Runs the thread.""" + with self.yielder: + super().run() + + +class InfiniteThread(GameThread): + """Thread that runs infinitely. + + Example: + + .. code:: python + + from threads import InfiniteThread + + class MyInfiniteThread(InfiniteThread): + def __call__(self): + with self.yielder: + ... + + thread = MyInfiniteThread().start(1) + """ + + @cached_property + def wait_signal(self): + """When clear, the current iteration is done waiting. + + :rtype: + threading.Event + """ + return Event() + + @cached_property + def exit_signal(self): + """When set, the thread has been stopped and terminated. + + :rtype: + threading.Event + """ + return Event() + + @cached_property + def interval(self): + """The time between each iteration. + + :rtype: + float + """ + return getswitchinterval() + + def __call__(self, *args, **kwargs): + """Calls the target with the given arguments. + + :param tuple *args: + The arguments to pass to the target function. + :param dict **kwargs: + The keyword arguments to pass to the target function. + + :return: + The value returned by the target function. + + .. note:: + + The thread will stop looping if a ``SystemExit`` is caught. + """ + with self.yielder: + return self._target(*args, **kwargs) + + def start(self, interval=None, wait=False): + """Starts iterating. + + :param float interval: + The time between each iteration. + :param bool wait: + Whether we should wait an interval for the first iteration. + + :return: + Return itself so that it can be inlined. + """ + # Set our interval if any was given + if interval is not None: + type(self).interval.set_cached_value(self, interval) + + # Set our wait signal so that we start iterating immediately + if not wait: + self.wait_signal.set() + + # Start iterating + super().start() + + # Clear our wait signal so that we start waiting again + if not wait: + self.wait_signal.clear() + + # Return ourself so that it can be inlined + return self + + def stop(self): + """Stops iterating.""" + # Set our exit signal so that we stop iterating + self.exit_signal.set() + + # Stop waiting for the next iteration + self.wait_signal.set() + + def run(self): + """Runs the thread. + + :raise SystemExit: + When the asynchronous state is being terminated. + """ + # Get our interval + interval = self.interval + + # Get our signals + exit_signal, wait_signal = self.exit_signal, self.wait_signal + + # Get our arguments + args, kwargs = self._args, self._kwargs + + # Start iterating + while True: + + # Wait the interval + if interval: + wait_signal.wait(interval) + + # Have we been stopped since the last iteration? + if exit_signal.is_set(): + + # Delete our target and arguments + del self._target, self._args, self._kwargs + + # Terminate the asynchronous state + raise SystemExit(f'{self.name} was terminated.') + + # Call the target with the given arguments + try: + self(*args, **kwargs) + except SystemExit: + self.stop() + except Exception: + except_hooks.print_exception() + + def _unload_instance(self): + """Stops iterating and unloads the instance.""" + # Stop iterating + self.stop() + + # Unload the instance + super()._unload_instance() + + +class Partial(partial): + """Represents a partial that can have a callback bound to it.""" + + def waitable(self): + """Makes the partial waitable when called from a parallel thread. + + :return: + Return itself so that it can be inlined. + """ + self._waitable = True + return self + + def unloadable(self): + """Makes the partial unloadable by the calling plugin. + + :return: + Return itself so that it can be inlined. + """ + # Get the module name of the calling plugin + caller = get_calling_plugin() + + # Flag the partial as unloadable + if caller is not None: + WeakAutoUnload._module_instances[caller][id(self)] = self + + # Return ourself so that it can be inlined + return self + + def with_callback(self, callback, *args, **kwargs): + """Binds the given callback to the instance. + + :param callable callback: + The callback to bind to this partial. + :param tuple *args: + The argument to pass to the callback. + :param dict **kwargs: + The keyword arguments to pass to the callback. + + :return: + Return itself so that it can be inlined. + + .. note:: + + The callback will be called in the main thread. + """ + self.callback = partial(callback, *args, **kwargs) + return self + + def __call__(self, *args, **kwargs): + """Calls the partial and pass the result to its callback. + + :param tuple *args: + The argument to pass to the partial. + :param dict **kwargs: + The keyword arguments to pass to the partial. + + :raise RuntimeError: + If the plugin that owns this partial was unloaded. + """ + # Sleep it off until the next context switch + try: + if self._waitable and current_thread() is not main_thread(): + sleep(getswitchinterval()) + except AttributeError: + pass + + # Raise if we were unloaded + try: + if self._unloaded: + raise RuntimeError('Partial was unloaded.') + except AttributeError: + pass + + # Call the partial and get the result + result = super().__call__(*args, **kwargs) + + # Try to resolve its callback + try: + callback = self.callback + except AttributeError: + return result + + # Call the callback with the result in the main thread + Delay(0, callback, args=(result,)) + + def _unload_instance(self): + """Flags the instance as unloaded.""" + self._unloaded = True + + +class Queued(WeakAutoUnload, Queue): + """Callables added to this queue are invoked one at a time + from a parallel thread. + + Example: + + .. code:: python + + from threads import Queued + + queue = Queued() + queue(print, 'This print was queued!') + + .. note:: + + If you don't need to manage your own queue, you should consider + using :func:`threads.queued` instead. + + .. warning:: + + If you mutate the internal queue, you are responsible to manage + the internal thread as well. + """ + + @cached_property + def thread(self): + """The internal thread that processes the queue. + + .. warning:: + + This should be left alone unless you manually mutate + the internal queue. + """ + with autounload_disabled(): + return InfiniteThread(self.process).start(wait=True) + + @thread.deleter + def _(self): + """Stops the internal thread.""" + self.thread.stop() + + @wraps(Queue.put) + def put(self, *args, **kwargs): + """Wrapper around `Queue.put` to ensure the thread is running.""" + super().put(*args, **kwargs) + self.thread + + @wraps(Queue.get) + def get(self, *args, **kwargs): + """Wrapper around `Queue.get` to stop the thread when empty.""" + result = super().get(*args, **kwargs) + if self.empty(): + del self.thread + self.task_done() + return result + + def clear(self): + """Empties the internal queue and invalidates the internal thread.""" + with self.mutex: + self.queue.clear() + del self.thread + + def __call__(self, function, *args, **kwargs): + """Adds the given function and argument to the queue. + + :param callable function: + The function to add to the queue. + :param tuple *args: + The arguments to pass to the given function. + :param dict **kwargs: + The keyword arguments to pass to the given function. + + :return: + The partial generated from the given function and arguments. + + :rtype: + Partial + """ + partial = Partial(function, *args, **kwargs) + self.put(partial) + return partial + + def process(self): + """Calls the next partial in the queue. + + :return: + The value returned by the called partial. + """ + partial = self.get() + if partial is None: + return + try: + if partial._unloaded: + return + except AttributeError: + pass + return partial() + + def _unload_instance(self): + """Empties the internal queue and invalidates the internal thread.""" + self.clear() + + +# ============================================================================= +# >> FUNCTIONS +# ============================================================================= +def queued(function, *args, **kwargs): + """Queues a call to the given function. + + :param callable function: + The function to call. + :param tuple *args: + The arguments to pass to the given function. + :param dict **kwargs: + The keyword arguments to pass to the given function. + + :rtype: + Partial + + Example: + + .. code:: python + + import time + from threads import queued + + def done_sleeping(start, result): + print(f'Done waiting and sleeping for {time.time() - start} seconds!') + + queued(time.sleep, 3).with_callback(done_sleeping, time.time()) + """ + global _queued + try: + _queued + except NameError: + with autounload_disabled(): + _queued = Queued() + return _queued(function, *args, **kwargs).unloadable() + + +def threaded(function, *args, **kwargs): + """Calls the given function with the given arguments in a new thread. + + :param callable function: + The function to call. + :param tuple *args: + The arguments to pass to the given function. + :param dict **kwargs: + The keyword arguments to pass to the given function. + + :rtype: + Partial + + Example: + + .. code:: python + + import time + from threads import threaded + + def sleep(seconds): + time.sleep(seconds) + return seconds + + def done_sleeping(result): + print(f'Done sleeping for {result} seconds!') + + threaded(sleep, 2).with_callback(done_sleeping) + + .. note:: + + If the call can wait its turn, consider :func:`threads.queued` instead. + """ + partial = Partial(function, *args, **kwargs).waitable() + GameThread(partial).start() + return partial diff --git a/resource/source-python/translations/_core/core_settings_strings.ini b/resource/source-python/translations/_core/core_settings_strings.ini index bd81f37bb..746b87dce 100644 --- a/resource/source-python/translations/_core/core_settings_strings.ini +++ b/resource/source-python/translations/_core/core_settings_strings.ini @@ -216,3 +216,9 @@ fr = 'Enregistre un avertissement lorsqu'une mise à jour pour Source.Python est ru = 'Писать сообщение в лог, если доступно обновление Source.Python. check_for_update должно быть установлено в 1.' es = 'Registar un aviso cuando haya una actualización disponible de Source.Python. Requiere check_for_updates estar en 1.' zh-cn = '当Source.Python的更新可用时,在日志记录旧版本警告. 需要check_for_update设置为1.' + +[enable_thread_yielding] +en = '''If enabled, yields remaining cycles to Python threads every frame. +If you don't know what you are doing, that probably means you don't need to enable it.''' +fr='''Lorsque activé, donne les cycles restants aux threads Python à chaque tick. +Si vous ne savez pas ce que vous faites, cela signifie probablement que vous n'avez pas besoin de l'activer.''' diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 65cd0411f..264c258a9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -466,6 +466,19 @@ Set(SOURCEPYTHON_STUDIO_MODULE_SOURCES core/modules/studio/studio_cache_wrap.cpp ) +# ------------------------------------------------------------------ +# Threads module +# ------------------------------------------------------------------ +Set(SOURCEPYTHON_THREADS_MODULE_HEADERS + core/modules/threads/threads.h + core/modules/threads/${SOURCE_ENGINE}/threads.h +) + +Set(SOURCEPYTHON_THREADS_MODULE_SOURCES + core/modules/threads/threads.cpp + core/modules/threads/threads_wrap.cpp +) + # ------------------------------------------------------------------ # Weapons module. # ------------------------------------------------------------------ @@ -565,6 +578,9 @@ Set(SOURCEPYTHON_MODULE_FILES ${SOURCEPYTHON_STUDIO_MODULE_HEADERS} ${SOURCEPYTHON_STUDIO_MODULE_SOURCES} + ${SOURCEPYTHON_THREADS_MODULE_HEADERS} + ${SOURCEPYTHON_THREADS_MODULE_SOURCES} + ${SOURCEPYTHON_WEAPONS_MODULE_HEADERS} ${SOURCEPYTHON_WEAPONS_MODULE_SOURCES} ) diff --git a/src/core/modules/core/core_cache.cpp b/src/core/modules/core/core_cache.cpp index ac92534fa..b9eb2e7cb 100644 --- a/src/core/modules/core/core_cache.cpp +++ b/src/core/modules/core/core_cache.cpp @@ -38,12 +38,12 @@ CCachedProperty::CCachedProperty( object fget=object(), object fset=object(), object fdel=object(), object doc=object(), boost::python::tuple args=boost::python::tuple(), object kwargs=object()) { + m_doc = doc; + set_getter(fget); set_setter(fset); set_deleter(fdel); - m_doc = doc; - m_args = args; if (!kwargs.is_none()) @@ -73,6 +73,11 @@ object CCachedProperty::get_getter() object CCachedProperty::set_getter(object fget) { m_fget = _callable_check(fget, "getter"); + + if (m_doc.is_none()) { + m_doc = m_fget.attr("__doc__"); + } + return fget; } diff --git a/src/core/modules/engines/engines.h b/src/core/modules/engines/engines.h index 41a713a42..a9d6fc2c8 100644 --- a/src/core/modules/engines/engines.h +++ b/src/core/modules/engines/engines.h @@ -199,4 +199,29 @@ class Ray_tExt }; +//----------------------------------------------------------------------------- +// IServerGameDLL wrapper class. +//----------------------------------------------------------------------------- +class IServerGameDLLWrapper : public IServerGameDLL +{ +public: + float m_fAutoSaveDangerousTime; + float m_fAutoSaveDangerousMinHealthToCommit; + bool m_bIsHibernating; +}; + + +//----------------------------------------------------------------------------- +// IServerGameDLL extension class. +//----------------------------------------------------------------------------- +class IServerGameDLLExt +{ +public: + static bool IsHibernating(IServerGameDLL *pServerGameDLL) + { + return reinterpret_cast(pServerGameDLL)->m_bIsHibernating; + } +}; + + #endif // _ENGINES_H diff --git a/src/core/modules/engines/engines_server_wrap.cpp b/src/core/modules/engines/engines_server_wrap.cpp index 0623789b5..8812a883f 100644 --- a/src/core/modules/engines/engines_server_wrap.cpp +++ b/src/core/modules/engines/engines_server_wrap.cpp @@ -787,6 +787,13 @@ static void export_server_game_dll(scope _server) class_ ServerGameDLL("_ServerGameDLL", no_init); // Methods... + ServerGameDLL.def( + "is_hibernating", + &IServerGameDLLExt::IsHibernating, + "Returns whether the server is currently hibernating." + ); + + // Properties... ServerGameDLL.add_property( "all_server_classes", make_function( diff --git a/src/core/modules/threads/blade/threads.h b/src/core/modules/threads/blade/threads.h new file mode 100644 index 000000000..f9040e710 --- /dev/null +++ b/src/core/modules/threads/blade/threads.h @@ -0,0 +1,38 @@ +/** +* ============================================================================= +* Source Python +* Copyright (C) 2012-2023 Source Python Development Team. All rights reserved. +* ============================================================================= +* +* This program is free software; you can redistribute it and/or modify it under +* the terms of the GNU General Public License, version 3.0, as published by the +* Free Software Foundation. +* +* This program is distributed in the hope that it will be useful, but WITHOUT +* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +* details. +* +* You should have received a copy of the GNU General Public License along with +* this program. If not, see . +* +* As a special exception, the Source Python Team gives you permission +* to link the code of this program (as well as its derivative works) to +* "Half-Life 2," the "Source Engine," and any Game MODs that run on software +* by the Valve Corporation. You must obey the GNU General Public License in +* all respects for all other code used. Additionally, the Source.Python +* Development Team grants this exception to all derivative works. +*/ + +#ifndef _THREADS_BLADE_H +#define _THREADS_BLADE_H + +#if defined(_WIN32) + #define TY_NotImplemented +#elif defined(__linux__) + #define TY_NotImplemented +#else + #define TY_NotImplemented +#endif + +#endif // _THREADS_BLADE_H diff --git a/src/core/modules/threads/bms/threads.h b/src/core/modules/threads/bms/threads.h new file mode 100644 index 000000000..94fe75db1 --- /dev/null +++ b/src/core/modules/threads/bms/threads.h @@ -0,0 +1,38 @@ +/** +* ============================================================================= +* Source Python +* Copyright (C) 2012-2023 Source Python Development Team. All rights reserved. +* ============================================================================= +* +* This program is free software; you can redistribute it and/or modify it under +* the terms of the GNU General Public License, version 3.0, as published by the +* Free Software Foundation. +* +* This program is distributed in the hope that it will be useful, but WITHOUT +* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +* details. +* +* You should have received a copy of the GNU General Public License along with +* this program. If not, see . +* +* As a special exception, the Source Python Team gives you permission +* to link the code of this program (as well as its derivative works) to +* "Half-Life 2," the "Source Engine," and any Game MODs that run on software +* by the Valve Corporation. You must obey the GNU General Public License in +* all respects for all other code used. Additionally, the Source.Python +* Development Team grants this exception to all derivative works. +*/ + +#ifndef _THREADS_BMS_H +#define _THREADS_BMS_H + +#if defined(_WIN32) + #define TY_NotImplemented +#elif defined(__linux__) + #define TY_NotImplemented +#else + #define TY_NotImplemented +#endif + +#endif // _THREADS_BMS_H diff --git a/src/core/modules/threads/csgo/threads.h b/src/core/modules/threads/csgo/threads.h new file mode 100644 index 000000000..4cc8afb3c --- /dev/null +++ b/src/core/modules/threads/csgo/threads.h @@ -0,0 +1,53 @@ +/** +* ============================================================================= +* Source Python +* Copyright (C) 2012-2023 Source Python Development Team. All rights reserved. +* ============================================================================= +* +* This program is free software; you can redistribute it and/or modify it under +* the terms of the GNU General Public License, version 3.0, as published by the +* Free Software Foundation. +* +* This program is distributed in the hope that it will be useful, but WITHOUT +* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +* details. +* +* You should have received a copy of the GNU General Public License along with +* this program. If not, see . +* +* As a special exception, the Source Python Team gives you permission +* to link the code of this program (as well as its derivative works) to +* "Half-Life 2," the "Source Engine," and any Game MODs that run on software +* by the Valve Corporation. You must obey the GNU General Public License in +* all respects for all other code used. Additionally, the Source.Python +* Development Team grants this exception to all derivative works. +*/ + +#ifndef _THREADS_CSGO_H +#define _THREADS_CSGO_H + +#if defined(_WIN32) + #include + #define TY_Unit ms + #define TY_Hook ThreadSleep + #define TY_Sleep Sleep + #define TY_Yield if (!SwitchToThread()) YieldProcessor +#elif defined(__linux__) + #include + #include + #define TY_Unit ns + extern "C" void ThreadNanoSleep(unsigned ns); + #define TY_Hook ThreadNanoSleep + #define TY_Sleep(ns) \ + struct timespec ts = { \ + .tv_sec = 0, \ + .tv_nsec = ns \ + }; \ + nanosleep(&ts, NULL); + #define TY_Yield ThreadPause +#else + #define TY_NotImplemented +#endif + +#endif // _THREADS_CSGO_H diff --git a/src/core/modules/threads/gmod/threads.h b/src/core/modules/threads/gmod/threads.h new file mode 100644 index 000000000..1851ec49b --- /dev/null +++ b/src/core/modules/threads/gmod/threads.h @@ -0,0 +1,38 @@ +/** +* ============================================================================= +* Source Python +* Copyright (C) 2012-2023 Source Python Development Team. All rights reserved. +* ============================================================================= +* +* This program is free software; you can redistribute it and/or modify it under +* the terms of the GNU General Public License, version 3.0, as published by the +* Free Software Foundation. +* +* This program is distributed in the hope that it will be useful, but WITHOUT +* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +* details. +* +* You should have received a copy of the GNU General Public License along with +* this program. If not, see . +* +* As a special exception, the Source Python Team gives you permission +* to link the code of this program (as well as its derivative works) to +* "Half-Life 2," the "Source Engine," and any Game MODs that run on software +* by the Valve Corporation. You must obey the GNU General Public License in +* all respects for all other code used. Additionally, the Source.Python +* Development Team grants this exception to all derivative works. +*/ + +#ifndef _THREADS_GMOD_H +#define _THREADS_GMOD_H + +#if defined(_WIN32) + #define TY_NotImplemented +#elif defined(__linux__) + #define TY_NotImplemented +#else + #define TY_NotImplemented +#endif + +#endif // _THREADS_GMOD_H diff --git a/src/core/modules/threads/l4d2/threads.h b/src/core/modules/threads/l4d2/threads.h new file mode 100644 index 000000000..955b59de5 --- /dev/null +++ b/src/core/modules/threads/l4d2/threads.h @@ -0,0 +1,38 @@ +/** +* ============================================================================= +* Source Python +* Copyright (C) 2012-2023 Source Python Development Team. All rights reserved. +* ============================================================================= +* +* This program is free software; you can redistribute it and/or modify it under +* the terms of the GNU General Public License, version 3.0, as published by the +* Free Software Foundation. +* +* This program is distributed in the hope that it will be useful, but WITHOUT +* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +* details. +* +* You should have received a copy of the GNU General Public License along with +* this program. If not, see . +* +* As a special exception, the Source Python Team gives you permission +* to link the code of this program (as well as its derivative works) to +* "Half-Life 2," the "Source Engine," and any Game MODs that run on software +* by the Valve Corporation. You must obey the GNU General Public License in +* all respects for all other code used. Additionally, the Source.Python +* Development Team grants this exception to all derivative works. +*/ + +#ifndef _THREADS_L4D2_H +#define _THREADS_L4D2_H + +#if defined(_WIN32) + #define TY_NotImplemented +#elif defined(__linux__) + #define TY_NotImplemented +#else + #define TY_NotImplemented +#endif + +#endif // _THREADS_L4D2_H diff --git a/src/core/modules/threads/orangebox/threads.h b/src/core/modules/threads/orangebox/threads.h new file mode 100644 index 000000000..790ffa265 --- /dev/null +++ b/src/core/modules/threads/orangebox/threads.h @@ -0,0 +1,52 @@ +/** +* ============================================================================= +* Source Python +* Copyright (C) 2012-2023 Source Python Development Team. All rights reserved. +* ============================================================================= +* +* This program is free software; you can redistribute it and/or modify it under +* the terms of the GNU General Public License, version 3.0, as published by the +* Free Software Foundation. +* +* This program is distributed in the hope that it will be useful, but WITHOUT +* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +* details. +* +* You should have received a copy of the GNU General Public License along with +* this program. If not, see . +* +* As a special exception, the Source Python Team gives you permission +* to link the code of this program (as well as its derivative works) to +* "Half-Life 2," the "Source Engine," and any Game MODs that run on software +* by the Valve Corporation. You must obey the GNU General Public License in +* all respects for all other code used. Additionally, the Source.Python +* Development Team grants this exception to all derivative works. +*/ + +#ifndef _THREADS_ORANGEBOX_H +#define _THREADS_ORANGEBOX_H + +#if defined(_WIN32) + #include + #define TY_Unit ms + #define TY_Hook ThreadSleep + #define TY_Sleep Sleep + #define TY_Yield if (!SwitchToThread()) YieldProcessor +#elif defined(__linux__) + #include + #include + #define TY_Unit us + #define TY_Hook usleep + #define TY_Sleep(us) \ + struct timespec ts = { \ + .tv_sec = (long int) (us / 1000000), \ + .tv_nsec = (long int) (us % 1000000) * 1000ul \ + }; \ + nanosleep(&ts, NULL); + #define TY_Yield ThreadPause +#else + #define TY_NotImplemented +#endif + +#endif // _THREADS_ORANGEBOX_H diff --git a/src/core/modules/threads/threads.cpp b/src/core/modules/threads/threads.cpp new file mode 100644 index 000000000..fecb69ad1 --- /dev/null +++ b/src/core/modules/threads/threads.cpp @@ -0,0 +1,127 @@ +/** +* ============================================================================= +* Source Python +* Copyright (C) 2012-2023 Source Python Development Team. All rights reserved. +* ============================================================================= +* +* This program is free software; you can redistribute it and/or modify it under +* the terms of the GNU General Public License, version 3.0, as published by the +* Free Software Foundation. +* +* This program is distributed in the hope that it will be useful, but WITHOUT +* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +* details. +* +* You should have received a copy of the GNU General Public License along with +* this program. If not, see . +* +* As a special exception, the Source Python Team gives you permission +* to link the code of this program (as well as its derivative works) to +* "Half-Life 2," the "Source Engine," and any Game MODs that run on software +* by the Valve Corporation. You must obey the GNU General Public License in +* all respects for all other code used. Additionally, the Source.Python +* Development Team grants this exception to all derivative works. +*/ + +//----------------------------------------------------------------------------- +// Includes. +//----------------------------------------------------------------------------- +#include "sp_main.h" +#include "threads.h" +#include "utilities.h" +#include "utilities/wrap_macros.h" + +#include "dbg.h" +#include "eiface.h" +#include "convar.h" +#include "tier0/threadtools.h" + +#include ENGINE_INCLUDE_PATH(threads.h) + + +//----------------------------------------------------------------------------- +// Externals. +//----------------------------------------------------------------------------- +extern CGlobalVars *gpGlobals; + + +//----------------------------------------------------------------------------- +// ConVar "sp_thread_yielding" registration. +//----------------------------------------------------------------------------- +ConVar sp_thread_yielding( + "sp_thread_yielding", "0", FCVAR_NONE, +#ifndef TY_NotImplemented + "If enabled, yields remaining cycles to Python threads every frame." +#else + "Thread yielding is not implemented on '" XSTRINGIFY(SOURCE_ENGINE) "' at this time." +#endif +); + + +//----------------------------------------------------------------------------- +// CThreadYielder initialization. +//----------------------------------------------------------------------------- +unsigned long CThreadYielder::s_nRefCount = 0; + + +//----------------------------------------------------------------------------- +// CThreadYielder class. +//----------------------------------------------------------------------------- +PyObject *CThreadYielder::__enter__(PyObject *pSelf) +{ + if (!sp_thread_yielding.GetBool()) { + return incref(pSelf); + } + +#ifndef TY_NotImplemented + ++s_nRefCount; + + static bool s_bHooked = false; + if (!s_bHooked) { + WriteJMP((unsigned char *)::TY_Hook, (void *)CThreadYielder::ThreadSleep); + s_bHooked = true; + } +#else + static object warn = import("warnings").attr("warn"); + warn("Thread yielding is not implemented on '" XSTRINGIFY(SOURCE_ENGINE) "' at this time."); +#endif + + return incref(pSelf); +} + +void CThreadYielder::__exit__(PyObject *, PyObject *, PyObject *, PyObject *) +{ +#ifndef TY_NotImplemented + if (!s_nRefCount) { + return; + } + + --s_nRefCount; +#endif +} + +void CThreadYielder::ThreadSleep(unsigned nTime) +{ +#ifndef TY_NotImplemented + if (!s_nRefCount || !ThreadInMainThread() || !PyGILState_Check()) { + TY_Sleep(nTime); + } + else { + Py_BEGIN_ALLOW_THREADS; + TY_Yield(); + TY_Sleep(nTime); + Py_END_ALLOW_THREADS; + DevMsg(2, MSG_PREFIX "Yielded %d%s on tick %d!\n", nTime, XSTRINGIFY(TY_Unit), gpGlobals->framecount); + } +#endif +} + +bool CThreadYielder::IsImplemented() +{ +#ifndef TY_NotImplemented + return true; +#else + return false; +#endif +} diff --git a/src/core/modules/threads/threads.h b/src/core/modules/threads/threads.h new file mode 100644 index 000000000..c005d680c --- /dev/null +++ b/src/core/modules/threads/threads.h @@ -0,0 +1,53 @@ +/** +* ============================================================================= +* Source Python +* Copyright (C) 2012-2023 Source Python Development Team. All rights reserved. +* ============================================================================= +* +* This program is free software; you can redistribute it and/or modify it under +* the terms of the GNU General Public License, version 3.0, as published by the +* Free Software Foundation. +* +* This program is distributed in the hope that it will be useful, but WITHOUT +* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +* details. +* +* You should have received a copy of the GNU General Public License along with +* this program. If not, see . +* +* As a special exception, the Source Python Team gives you permission +* to link the code of this program (as well as its derivative works) to +* "Half-Life 2," the "Source Engine," and any Game MODs that run on software +* by the Valve Corporation. You must obey the GNU General Public License in +* all respects for all other code used. Additionally, the Source.Python +* Development Team grants this exception to all derivative works. +*/ + +#ifndef _THREADS_H +#define _THREADS_H + +//----------------------------------------------------------------------------- +// Includes. +//----------------------------------------------------------------------------- +#include "boost/python.hpp" + + +//----------------------------------------------------------------------------- +// CThreadYielder class. +//----------------------------------------------------------------------------- +class CThreadYielder +{ +private: + static unsigned long s_nRefCount; + +public: + static PyObject *__enter__(PyObject *pSelf); + static void __exit__(PyObject *, PyObject *, PyObject *, PyObject *); + + static void ThreadSleep(unsigned nTime = 0); + static bool IsImplemented(); +}; + + +#endif // _THREADS_H diff --git a/src/core/modules/threads/threads_wrap.cpp b/src/core/modules/threads/threads_wrap.cpp new file mode 100644 index 000000000..a59542427 --- /dev/null +++ b/src/core/modules/threads/threads_wrap.cpp @@ -0,0 +1,83 @@ +/** +* ============================================================================= +* Source Python +* Copyright (C) 2012-2023 Source Python Development Team. All rights reserved. +* ============================================================================= +* +* This program is free software; you can redistribute it and/or modify it under +* the terms of the GNU General Public License, version 3.0, as published by the +* Free Software Foundation. +* +* This program is distributed in the hope that it will be useful, but WITHOUT +* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +* details. +* +* You should have received a copy of the GNU General Public License along with +* this program. If not, see . +* +* As a special exception, the Source Python Team gives you permission +* to link the code of this program (as well as its derivative works) to +* "Half-Life 2," the "Source Engine," and any Game MODs that run on software +* by the Valve Corporation. You must obey the GNU General Public License in +* all respects for all other code used. Additionally, the Source.Python +* Development Team grants this exception to all derivative works. +*/ + +//----------------------------------------------------------------------------- +// Includes. +//----------------------------------------------------------------------------- +#include "export_main.h" +#include "threads.h" +#include "modules/memory/memory_tools.h" + + +//----------------------------------------------------------------------------- +// Namespaces. +//----------------------------------------------------------------------------- +using namespace boost::python; + + +//----------------------------------------------------------------------------- +// Forward declarations. +//----------------------------------------------------------------------------- +static void export_thread_yielder(scope); + + +//----------------------------------------------------------------------------- +// Declare the _threads module. +//----------------------------------------------------------------------------- +DECLARE_SP_MODULE(_threads) +{ + export_thread_yielder(_threads); +} + + +//----------------------------------------------------------------------------- +// Exports CThreadYielder. +//----------------------------------------------------------------------------- +void export_thread_yielder(scope _threads) +{ + class_ ThreadYielder( + "ThreadYielder", + "When in context, yields remaining cycles to Python threads every frame.\n" + "\n" + ".. note::\n" + "\n" + " :data:`threads.sp_thread_yielding` must be enabled for it to be effective." + ); + + // Special methods... + ThreadYielder.def("__enter__", &CThreadYielder::__enter__); + ThreadYielder.def("__exit__", &CThreadYielder::__exit__); + + // Methods... + ThreadYielder.def( + "is_implemented", + &CThreadYielder::IsImplemented, + "Returns whether thread yielding is implemented on the current game." + ).staticmethod("is_implemented"); + + // Add memory tools... + ThreadYielder ADD_MEM_TOOLS(CThreadYielder); +}