From 9ea2989bb926fdc1569c300cb517e5c3e0ad1549 Mon Sep 17 00:00:00 2001 From: Eric Froemling Date: Thu, 12 Nov 2020 14:56:32 -0800 Subject: [PATCH] Work in progress on new server config options --- .efrocachemap | 36 ++-- assets/src/ba_data/python/ba/_servermode.py | 6 +- .../python/bastd/ui/playlist/editgame.py | 17 +- .../python/bastd/ui/playlist/mapselect.py | 12 +- assets/src/server/ballisticacore_server.py | 167 +++++++++++------- docs/ba_module.md | 2 +- src/ballistica/ballistica.cc | 2 +- tools/bacommon/servermanager.py | 19 ++ tools/batools/build.py | 6 + 9 files changed, 169 insertions(+), 98 deletions(-) diff --git a/.efrocachemap b/.efrocachemap index a70baa7f..7a0b58b3 100644 --- a/.efrocachemap +++ b/.efrocachemap @@ -420,7 +420,7 @@ "assets/build/ba_data/audio/zoeOw.ogg": "https://files.ballistica.net/cache/ba1/04/0a/c4f7d2794b018593ab0b2bcb07f0", "assets/build/ba_data/audio/zoePickup01.ogg": "https://files.ballistica.net/cache/ba1/06/4d/18777c9a2eb2207a2891a2837a70", "assets/build/ba_data/audio/zoeScream01.ogg": "https://files.ballistica.net/cache/ba1/81/90/23ab1ecc8c55267bd904a9c05344", - "assets/build/ba_data/data/langdata.json": "https://files.ballistica.net/cache/ba1/58/c6/e4221985f6a7c871c5fe9a936d11", + "assets/build/ba_data/data/langdata.json": "https://files.ballistica.net/cache/ba1/71/5f/2fc5c6d238f6e84926ecabd52e8c", "assets/build/ba_data/data/languages/arabic.json": "https://files.ballistica.net/cache/ba1/8b/fa/719ccefcd94822218fcedb9d5038", "assets/build/ba_data/data/languages/belarussian.json": "https://files.ballistica.net/cache/ba1/c1/2b/54aeb92c709c4af443f4a9013b3d", "assets/build/ba_data/data/languages/chinese.json": "https://files.ballistica.net/cache/ba1/69/cc/f8bdd1e83162481c6bf2a78cb5e0", @@ -445,7 +445,7 @@ "assets/build/ba_data/data/languages/portuguese.json": "https://files.ballistica.net/cache/ba1/59/49/d75f2b9916541bd2be8c49a5171f", "assets/build/ba_data/data/languages/romanian.json": "https://files.ballistica.net/cache/ba1/44/3c/7cc06ca8d5475e1687d0ed05bdbf", "assets/build/ba_data/data/languages/russian.json": "https://files.ballistica.net/cache/ba1/38/0e/808588bf5140deae5107b61f1df7", - "assets/build/ba_data/data/languages/serbian.json": "https://files.ballistica.net/cache/ba1/e7/d8/ace32888249fc8b8cca0e2edb48b", + "assets/build/ba_data/data/languages/serbian.json": "https://files.ballistica.net/cache/ba1/3c/fe/60ad0f2a58aa1ffb742f0e8163e4", "assets/build/ba_data/data/languages/slovak.json": "https://files.ballistica.net/cache/ba1/b7/0a/fab820b96e7aa587ee56427ecdc2", "assets/build/ba_data/data/languages/spanish.json": "https://files.ballistica.net/cache/ba1/67/7b/e4c6c03f889b6e1bcf39345fa197", "assets/build/ba_data/data/languages/swedish.json": "https://files.ballistica.net/cache/ba1/50/9f/be006ba19be6a69a57837eb6dca0", @@ -3932,24 +3932,24 @@ "assets/build/windows/Win32/ucrtbased.dll": "https://files.ballistica.net/cache/ba1/b5/85/f8b6d0558ddb87267f34254b1450", "assets/build/windows/Win32/vc_redist.x86.exe": "https://files.ballistica.net/cache/ba1/1c/e1/4a1a2eddda2f4aebd5f8b64ab08e", "assets/build/windows/Win32/vcruntime140d.dll": "https://files.ballistica.net/cache/ba1/50/8d/bc2600ac9491f1b14d659709451f", - "build/prefab/full/linux_x86_64/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/d2/35/5ff7dd7a25041c0784ba74b40137", - "build/prefab/full/linux_x86_64/release/ballisticacore": "https://files.ballistica.net/cache/ba1/58/f5/97d56302b2320c328a1702b29b30", - "build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/bd/95/c2eee0447fd64a41a1f36e31e257", - "build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/26/d1/3c2b8fde63213bfdfcb975d7bbaa", - "build/prefab/full/mac_x86_64/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/d7/94/c49ff5a94f47d2886c8db004c2ec", - "build/prefab/full/mac_x86_64/release/ballisticacore": "https://files.ballistica.net/cache/ba1/5f/c5/d328666183caf3acb4a315b175c3", - "build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/b9/87/ac5d264cafb87348ad2e791a3393", - "build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/d4/e3/0f65cb6c998aad83ac0f26075951", - "build/prefab/full/windows_x86/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/e7/bc/261d8d63a33d6a0e17fb20477ffd", - "build/prefab/full/windows_x86/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/44/23/5dd042e6845fef6e3690bd4d5734", - "build/prefab/full/windows_x86_server/debug/dist/ballisticacore_headless.exe": "https://files.ballistica.net/cache/ba1/ea/c6/cdc48f95725a16297b222de3c69e", - "build/prefab/full/windows_x86_server/release/dist/ballisticacore_headless.exe": "https://files.ballistica.net/cache/ba1/64/c8/5ccec4eba9397107f075cf2ab401", + "build/prefab/full/linux_x86_64/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/03/b2/f474954f36b60e6c630d37816a29", + "build/prefab/full/linux_x86_64/release/ballisticacore": "https://files.ballistica.net/cache/ba1/8c/9a/2f88355ff7e46f9a9a3457890497", + "build/prefab/full/linux_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/e6/66/9b7795c8d90d9d7815490bfb1574", + "build/prefab/full/linux_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/94/17/757605bdea7280b1b559a20a4506", + "build/prefab/full/mac_x86_64/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/b7/9a/300d3ed36f3f804064698e97884a", + "build/prefab/full/mac_x86_64/release/ballisticacore": "https://files.ballistica.net/cache/ba1/ce/1c/1d030991c481b8943746a2cbba40", + "build/prefab/full/mac_x86_64_server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/8c/93/01bcf74f104fbcc681efaa2d4fa4", + "build/prefab/full/mac_x86_64_server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/74/e1/35e77d9346313158b68c8a3bc37d", + "build/prefab/full/windows_x86/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/60/46/646603dcf25085aeb2809401dd1c", + "build/prefab/full/windows_x86/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/9c/20/50d3a17814c6994531178d62ec0f", + "build/prefab/full/windows_x86_server/debug/dist/ballisticacore_headless.exe": "https://files.ballistica.net/cache/ba1/47/8d/35b1f14712c5fb48d9f7b3467898", + "build/prefab/full/windows_x86_server/release/dist/ballisticacore_headless.exe": "https://files.ballistica.net/cache/ba1/57/18/dc3024133e4f01fb52682ba3ad6a", "build/prefab/lib/linux_x86_64/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/4f/4c/8590730e5d1cdae456c1b734a2a1", "build/prefab/lib/linux_x86_64/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/44/e5/d1c3162e114e51a5b5b826c2ec7c", "build/prefab/lib/linux_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/ee/21/8fab3da6b974cf323024d076b609", "build/prefab/lib/linux_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/20/5d/881676243c5f44bdca677497b4d4", - "build/prefab/lib/mac_x86_64/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/af/fd/b522dcf75713cd293743cff84613", - "build/prefab/lib/mac_x86_64/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/15/45/0c8a4e7775c699146aea682e5504", - "build/prefab/lib/mac_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/09/31/4cf5f0cd0c180f9ecbb973b07ed9", - "build/prefab/lib/mac_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/4a/1d/be1767741e32fd937917748944d6" + "build/prefab/lib/mac_x86_64/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/94/0b/86965867c6e7d5f984464456240f", + "build/prefab/lib/mac_x86_64/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/eb/aa/536569d7cf92e2ef88ecedcb68e9", + "build/prefab/lib/mac_x86_64_server/debug/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/e5/54/9bedbacbc1a98f2b3d945a543614", + "build/prefab/lib/mac_x86_64_server/release/libballisticacore_internal.a": "https://files.ballistica.net/cache/ba1/69/68/edb0797f52cf1684d53c2e84d449" } \ No newline at end of file diff --git a/assets/src/ba_data/python/ba/_servermode.py b/assets/src/ba_data/python/ba/_servermode.py index 75e11b61..a0d74209 100644 --- a/assets/src/ba_data/python/ba/_servermode.py +++ b/assets/src/ba_data/python/ba/_servermode.py @@ -8,14 +8,14 @@ import time from typing import TYPE_CHECKING from efro.terminal import Clr -from ba._enums import TimeType -from ba._freeforallsession import FreeForAllSession -from ba._dualteamsession import DualTeamSession from bacommon.servermanager import (ServerCommand, StartServerModeCommand, ShutdownCommand, ShutdownReason, ChatMessageCommand, ScreenMessageCommand, ClientListCommand, KickCommand) import _ba +from ba._enums import TimeType +from ba._freeforallsession import FreeForAllSession +from ba._dualteamsession import DualTeamSession if TYPE_CHECKING: from typing import Optional, Dict, Any, Type diff --git a/assets/src/ba_data/python/bastd/ui/playlist/editgame.py b/assets/src/ba_data/python/bastd/ui/playlist/editgame.py index 1c0758c2..8533cc11 100644 --- a/assets/src/ba_data/python/bastd/ui/playlist/editgame.py +++ b/assets/src/ba_data/python/bastd/ui/playlist/editgame.py @@ -1,6 +1,6 @@ # Released under the MIT License. See LICENSE for details. # -"""Provides UI for editing a game in a playlist.""" +"""Provides UI for editing a game config.""" from __future__ import annotations @@ -16,10 +16,10 @@ if TYPE_CHECKING: class PlaylistEditGameWindow(ba.Window): - """Window for editing a game in a playlist.""" + """Window for editing a game config.""" def __init__(self, - gameclass: Type[ba.GameActivity], + gametype: Type[ba.GameActivity], sessiontype: Type[ba.Session], config: Optional[Dict[str, Any]], completion_call: Callable[[Optional[Dict[str, Any]]], Any], @@ -31,7 +31,7 @@ class PlaylistEditGameWindow(ba.Window): # pylint: disable=too-many-locals from ba.internal import (get_unowned_maps, get_filtered_map_name, get_map_class, get_map_display_string) - self._gameclass = gameclass + self._gametype = gametype self._sessiontype = sessiontype # If we're within an editing session we get passed edit_info @@ -49,12 +49,12 @@ class PlaylistEditGameWindow(ba.Window): self._r = 'gameSettingsWindow' - valid_maps = gameclass.get_supported_maps(sessiontype) + valid_maps = gametype.get_supported_maps(sessiontype) if not valid_maps: ba.screenmessage(ba.Lstr(resource='noValidMapsErrorText')) raise Exception('No valid maps') - self._settings_defs = gameclass.get_available_settings(sessiontype) + self._settings_defs = gametype.get_available_settings(sessiontype) self._completion_call = completion_call # To start with, pick a random map out of the ones we own. @@ -140,7 +140,7 @@ class PlaylistEditGameWindow(ba.Window): ba.textwidget(parent=self._root_widget, position=(-8, height - 70 + y_extra2), size=(width, 25), - text=gameclass.get_display_string(), + text=gametype.get_display_string(), color=ba.app.ui.title_color, maxwidth=235, scale=1.1, @@ -241,7 +241,6 @@ class PlaylistEditGameWindow(ba.Window): # Handle types with choices specially: if isinstance(setting, ba.ChoiceSetting): - # if 'choices' in setting: for choice in setting.choices: if len(choice) != 2: raise ValueError( @@ -429,7 +428,7 @@ class PlaylistEditGameWindow(ba.Window): # Replace ourself with the map-select UI. ba.containerwidget(edit=self._root_widget, transition='out_left') ba.app.ui.set_main_menu_window( - PlaylistMapSelectWindow(self._gameclass, self._sessiontype, + PlaylistMapSelectWindow(self._gametype, self._sessiontype, copy.deepcopy(self._getconfig()), self._edit_info, self._completion_call).get_root_widget()) diff --git a/assets/src/ba_data/python/bastd/ui/playlist/mapselect.py b/assets/src/ba_data/python/bastd/ui/playlist/mapselect.py index 5c27c718..2381f6c5 100644 --- a/assets/src/ba_data/python/bastd/ui/playlist/mapselect.py +++ b/assets/src/ba_data/python/bastd/ui/playlist/mapselect.py @@ -18,14 +18,14 @@ class PlaylistMapSelectWindow(ba.Window): """Window to select a map.""" def __init__(self, - gameclass: Type[ba.GameActivity], + gametype: Type[ba.GameActivity], sessiontype: Type[ba.Session], config: Dict[str, Any], edit_info: Dict[str, Any], completion_call: Callable[[Optional[Dict[str, Any]]], Any], transition: str = 'in_right'): from ba.internal import get_filtered_map_name - self._gameclass = gameclass + self._gametype = gametype self._sessiontype = sessiontype self._config = config self._completion_call = completion_call @@ -69,7 +69,7 @@ class PlaylistMapSelectWindow(ba.Window): scale=1.1, text=ba.Lstr(resource='mapSelectTitleText', subs=[('${GAME}', - self._gameclass.get_display_string()) + self._gametype.get_display_string()) ]), color=ba.app.ui.title_color, h_align='center', @@ -104,7 +104,7 @@ class PlaylistMapSelectWindow(ba.Window): model_transparent = ba.getmodel('level_select_button_transparent') self._maps = [] - map_list = self._gameclass.get_supported_maps(self._sessiontype) + map_list = self._gametype.get_supported_maps(self._sessiontype) map_list_sorted = list(map_list) map_list_sorted.sort() unowned_maps = get_unowned_maps() @@ -227,7 +227,7 @@ class PlaylistMapSelectWindow(ba.Window): ba.containerwidget(edit=self._root_widget, transition='out_right') ba.app.ui.set_main_menu_window( PlaylistEditGameWindow( - self._gameclass, + self._gametype, self._sessiontype, self._config, self._completion_call, @@ -247,7 +247,7 @@ class PlaylistMapSelectWindow(ba.Window): ba.containerwidget(edit=self._root_widget, transition='out_right') ba.app.ui.set_main_menu_window( PlaylistEditGameWindow( - self._gameclass, + self._gametype, self._sessiontype, self._config, self._completion_call, diff --git a/assets/src/server/ballisticacore_server.py b/assets/src/server/ballisticacore_server.py index 89247ad7..6720e670 100755 --- a/assets/src/server/ballisticacore_server.py +++ b/assets/src/server/ballisticacore_server.py @@ -6,6 +6,7 @@ from __future__ import annotations import json import os +import signal import subprocess import sys import time @@ -33,7 +34,7 @@ if TYPE_CHECKING: # Not sure how much versioning we'll do with this, but this will get # printed at startup in case we need it. -VERSION_STR = '1.0.2' +VERSION_STR = '1.1.0' class ServerManagerApp: @@ -48,15 +49,26 @@ class ServerManagerApp: self._config = self._load_config() except Exception as exc: raise CleanError(f'Error loading config: {exc}') from exc + self._shutdown_desired = False self._done = False - self._process_commands: List[Union[str, ServerCommand]] = [] - self._process_commands_lock = Lock() - self._restart_minutes: Optional[float] = 360.0 + self._subprocess_commands: List[Union[str, ServerCommand]] = [] + self._subprocess_commands_lock = Lock() + self._restart_minutes: Optional[float] = None self._running_interactive = False - self._process: Optional[subprocess.Popen[bytes]] = None - self._process_launch_time: Optional[float] = None - self._process_sent_auto_restart = False - self._process_thread: Optional[Thread] = None + self._subprocess: Optional[subprocess.Popen[bytes]] = None + self._launch_time = time.time() + self._subprocess_launch_time: Optional[float] = None + self._subprocess_sent_auto_restart = False + self._subprocess_sent_unclean_exit = False + self._subprocess_thread: Optional[Thread] = None + + # If we don't have any explicit exit conditions set, + # we run indefinitely (though we restart our subprocess + # periodically to clear out leaks/cruft) + if (self._config.clean_exit_minutes is None + and self._config.unclean_exit_minutes is None + and self._config.idle_exit_minutes is None): + self._restart_minutes = 360.0 @property def config(self) -> ServerConfig: @@ -80,7 +92,6 @@ class ServerManagerApp: def run_interactive(self) -> None: """Run the app loop to completion.""" import code - import signal if self._running_interactive: raise RuntimeError('Already running interactively.') @@ -106,8 +117,8 @@ class ServerManagerApp: signal.signal(signal.SIGTERM, self._handle_term_signal) # Fire off a background thread to wrangle our server binaries. - self._process_thread = Thread(target=self._bg_thread_main) - self._process_thread.start() + self._subprocess_thread = Thread(target=self._bg_thread_main) + self._subprocess_thread.start() context = {'__name__': '__console__', '__doc__': None, 'mgr': self} @@ -138,7 +149,7 @@ class ServerManagerApp: # Mark ourselves as shutting down and wait for the process to wrap up. self._done = True - self._process_thread.join() + self._subprocess_thread.join() def cmd(self, statement: str) -> None: """Exec a Python command on the current running server child-process. @@ -148,8 +159,8 @@ class ServerManagerApp: """ if not isinstance(statement, str): raise TypeError(f'Expected a string arg; got {type(statement)}') - with self._process_commands_lock: - self._process_commands.append(statement) + with self._subprocess_commands_lock: + self._subprocess_commands.append(statement) self._block_for_command_completion() def _block_for_command_completion(self) -> None: @@ -159,8 +170,8 @@ class ServerManagerApp: # it. In the future we can perhaps add a proper 'command port' # interface for proper blocking two way communication. while True: - with self._process_commands_lock: - if not self._process_commands: + with self._subprocess_commands_lock: + if not self._subprocess_commands: break time.sleep(0.1) @@ -225,6 +236,20 @@ class ServerManagerApp: ShutdownCommand(reason=ShutdownReason.RESTARTING, immediate=immediate)) + def shutdown(self, immediate: bool = False) -> None: + """Shut down the server child-process and exit the wrapper + + By default, the server will exit at the next good transition + point (end of a series, etc) but passing immediate=True will stop + it immediately. + """ + from bacommon.servermanager import ShutdownCommand, ShutdownReason + self._enqueue_server_command( + ShutdownCommand(reason=ShutdownReason.NONE, immediate=immediate)) + + # So we know to bail completely once this subprocess completes. + self._shutdown_desired = True + def _load_config(self) -> ServerConfig: user_config_path = 'config.yaml' @@ -266,11 +291,11 @@ class ServerManagerApp: def _run_server_cycle(self) -> None: """Spin up the server child-process and run it until exit.""" - self._prep_process_environment() + self._prep_subprocess_environment() # Launch the binary and grab its stdin; # we'll use this to feed it commands. - self._process_launch_time = time.time() + self._subprocess_launch_time = time.time() # Set an environment var so the server process knows its being # run under us. This causes it to ignore ctrl-c presses and other @@ -280,18 +305,26 @@ class ServerManagerApp: print(f'{Clr.CYN}Launching server child-process...{Clr.RST}') binary_name = ('ballisticacore_headless.exe' if os.name == 'nt' else './ballisticacore_headless') - self._process = subprocess.Popen([binary_name, '-cfgdir', 'ba_root'], - stdin=subprocess.PIPE, - cwd='dist') + self._subprocess = subprocess.Popen( + [binary_name, '-cfgdir', 'ba_root'], + stdin=subprocess.PIPE, + cwd='dist') # Do the thing. # No matter how this ends up, make sure the process is dead after. try: - self._run_process_until_exit() + self._run_subprocess_until_exit() finally: - self._kill_process() + self._kill_subprocess() - def _prep_process_environment(self) -> None: + if self._shutdown_desired: + self._done = True + + # Our main thread will still be blocked in its prompt or whatnot; + # let it know it should die. + os.kill(os.getpid(), signal.SIGTERM) + + def _prep_subprocess_environment(self) -> None: """Write files that must exist at process launch.""" os.makedirs('dist/ba_root', exist_ok=True) if os.path.exists('dist/ba_root/config.json'): @@ -313,8 +346,8 @@ class ServerManagerApp: Can be called from any thread. """ - with self._process_commands_lock: - self._process_commands.append(command) + with self._subprocess_commands_lock: + self._subprocess_commands.append(command) def _send_server_command(self, command: ServerCommand) -> None: """Send a command to the server. @@ -322,19 +355,20 @@ class ServerManagerApp: Must be called from the server process thread. """ import pickle - assert current_thread() is self._process_thread - assert self._process is not None - assert self._process.stdin is not None + assert current_thread() is self._subprocess_thread + assert self._subprocess is not None + assert self._subprocess.stdin is not None val = repr(pickle.dumps(command)) assert '\n' not in val execcode = (f'import ba._servermode;' f' ba._servermode._cmd({val})\n').encode() - self._process.stdin.write(execcode) - self._process.stdin.flush() + self._subprocess.stdin.write(execcode) + self._subprocess.stdin.flush() - def _run_process_until_exit(self) -> None: - assert self._process is not None - assert self._process.stdin is not None + def _run_subprocess_until_exit(self) -> None: + assert self._subprocess is not None + assert self._subprocess.stdin is not None + assert self._subprocess_launch_time is not None # Send the initial server config which should kick things off. # (but make sure its values are still valid first) @@ -348,31 +382,41 @@ class ServerManagerApp: break # Pass along any commands to our process. - with self._process_commands_lock: - for incmd in self._process_commands: + with self._subprocess_commands_lock: + for incmd in self._subprocess_commands: # If we're passing a raw string to exec, no need to wrap it # in any proper structure. if isinstance(incmd, str): - self._process.stdin.write((incmd + '\n').encode()) - self._process.stdin.flush() + self._subprocess.stdin.write((incmd + '\n').encode()) + self._subprocess.stdin.flush() else: self._send_server_command(incmd) - self._process_commands = [] + self._subprocess_commands = [] - # Request a soft restart after a while. - assert self._process_launch_time is not None - sincelaunch = time.time() - self._process_launch_time + # Request restarts/shut-downs for various reasons. + sincelaunch = time.time() - self._subprocess_launch_time if (self._restart_minutes is not None and sincelaunch > (self._restart_minutes * 60.0) - and not self._process_sent_auto_restart): + and not self._subprocess_sent_auto_restart): print(f'{Clr.CYN}restart_minutes ({self._restart_minutes})' f' elapsed; requesting child-process' f' soft restart...{Clr.RST}') self.restart() - self._process_sent_auto_restart = True + self._subprocess_sent_auto_restart = True + if self._config.unclean_exit_minutes is not None: + elapsed = (time.time() - self._launch_time) / 60.0 + threshold = self._config.unclean_exit_minutes + if (elapsed > threshold + and not self._subprocess_sent_unclean_exit): + print(f'{Clr.CYN}unclean_exit_minutes' + f' ({threshold})' + f' elapsed; requesting child-process' + f' shutdown...{Clr.RST}') + self.shutdown(immediate=True) + self._subprocess_sent_unclean_exit = True - # Watch for the process exiting. - code: Optional[int] = self._process.poll() + # Watch for the process exiting.. + code: Optional[int] = self._subprocess.poll() if code is not None: if code == 0: clr = Clr.CYN @@ -382,41 +426,44 @@ class ServerManagerApp: slp = 5.0 # Avoid super fast death loops. print(f'{clr}Server child-process exited' f' with code {code}.{Clr.RST}') - self._reset_process_vars() + self._reset_subprocess_vars() time.sleep(slp) break time.sleep(0.25) - def _reset_process_vars(self) -> None: - self._process = None - self._process_launch_time = None - self._process_sent_auto_restart = False + def _reset_subprocess_vars(self) -> None: + self._subprocess = None + self._subprocess_launch_time = None + self._subprocess_sent_auto_restart = False + self._subprocess_sent_unclean_exit = False - def _kill_process(self) -> None: + def _kill_subprocess(self) -> None: """End the server process if it still exists.""" - assert current_thread() is self._process_thread - if self._process is None: + assert current_thread() is self._subprocess_thread + if self._subprocess is None: return print(f'{Clr.CYN}Stopping server process...{Clr.RST}') # First, ask it nicely to die and give it a moment. # If that doesn't work, bring down the hammer. - self._process.terminate() + self._subprocess.terminate() try: - self._process.wait(timeout=10) + self._subprocess.wait(timeout=10) except subprocess.TimeoutExpired: - self._process.kill() - self._reset_process_vars() + self._subprocess.kill() + self._reset_subprocess_vars() print(f'{Clr.CYN}Server process stopped.{Clr.RST}') def main() -> None: """Run a BallisticaCore server manager in interactive mode.""" try: - # Change our working directory according to file's path - # so that this script can be run from anywhere. + # ServerManager expects cwd to be the server dir (containing + # dist/, config.yaml, etc.) + # Let's change our working directory to the location of this file + # so we can run this script from anywhere and it'll work. os.chdir(os.path.abspath(os.path.dirname(__file__))) ServerManagerApp().run_interactive() diff --git a/docs/ba_module.md b/docs/ba_module.md index 4938ca0b..1674f850 100644 --- a/docs/ba_module.md +++ b/docs/ba_module.md @@ -1,5 +1,5 @@ -

last updated on 2020-11-11 for Ballistica version 1.5.28 build 20246

+

last updated on 2020-11-12 for Ballistica version 1.5.29 build 20248

This page documents the Python classes and functions in the 'ba' module, which are the ones most relevant to modding in Ballistica. If you come across something you feel should be included here or could be better explained, please let me know. Happy modding!


diff --git a/src/ballistica/ballistica.cc b/src/ballistica/ballistica.cc index f449c73f..02d8264e 100644 --- a/src/ballistica/ballistica.cc +++ b/src/ballistica/ballistica.cc @@ -21,7 +21,7 @@ namespace ballistica { // These are set automatically via script; don't change here. -const int kAppBuildNumber = 20247; +const int kAppBuildNumber = 20248; const char* kAppVersion = "1.5.29"; // Our standalone globals. diff --git a/tools/bacommon/servermanager.py b/tools/bacommon/servermanager.py index 5ff36eae..c351ee91 100644 --- a/tools/bacommon/servermanager.py +++ b/tools/bacommon/servermanager.py @@ -91,6 +91,25 @@ class ServerConfig: # http://bombsquadgame.com/accountquery?id=ACCOUNT_ID_HERE stats_url: Optional[str] = None + # If present, the server will attempt to gracefully exit after this + # amount of time. A graceful exit can occur at the end of a series + # or other opportune time. + # Servers with no exit times set will run indefinitely (though the server + # binary will be restarted periodically to clear any leaked memory). + clean_exit_minutes: Optional[float] = None + + # If present, the server will shut down immediately after the given + # amount of time). This can be useful as a fallback for clean_exit_time. + # Servers with no exit times set will run indefinitely (though the server + # binary will be restarted periodically to clear any leaked memory). + unclean_exit_minutes: Optional[float] = None + + # If present, the server will shut down immediately if this amount of + # time passes with no connected clients. + # Servers with no exit times set will run indefinitely (though the server + # binary will be restarted periodically to clear any leaked memory). + idle_exit_minutes: Optional[float] = None + # NOTE: as much as possible, communication from the server-manager to the # child-process should go through these and not ad-hoc Python string commands diff --git a/tools/batools/build.py b/tools/batools/build.py index b82e7eb7..96867836 100644 --- a/tools/batools/build.py +++ b/tools/batools/build.py @@ -642,6 +642,12 @@ def _get_server_config_template_yaml(projroot: str) -> str: if vname == 'playlist_code': # User wouldn't want to pass the default of None here. vval = 12345 + elif vname == 'clean_exit_time': + vval = 60 + elif vname == 'unclean_exit_time': + vval = 90 + elif vname == 'idle_exit_time': + vval = 20 elif vname == 'stats_url': vval = ('https://mystatssite.com/' 'showstats?player=${ACCOUNT}')