From 3e3b1b0fc0d07f2478564ff96ca3f0002b958607 Mon Sep 17 00:00:00 2001 From: Eric Froemling Date: Mon, 27 Apr 2020 21:53:35 -0700 Subject: [PATCH] Refactored server wrapper to use interactive python interpreter --- .idea/dictionaries/ericf.xml | 3 + assets/src/ba_data/python/ba/_server.py | 3 +- assets/src/server/server.py | 100 ++++++++++++++++++------ 3 files changed, 79 insertions(+), 27 deletions(-) diff --git a/.idea/dictionaries/ericf.xml b/.idea/dictionaries/ericf.xml index 081c6f81..f31a68cf 100644 --- a/.idea/dictionaries/ericf.xml +++ b/.idea/dictionaries/ericf.xml @@ -86,6 +86,7 @@ armeabi arraymodule asdict + aspx assertnode assetbundle assetcache @@ -161,6 +162,7 @@ bgmodel bgterrain bgtex + bgthread bhval binc bindcode @@ -1328,6 +1330,7 @@ pragmas prch prec + preexec preflightfast preflightfull preflighting diff --git a/assets/src/ba_data/python/ba/_server.py b/assets/src/ba_data/python/ba/_server.py index 41da4527..bfdadf55 100644 --- a/assets/src/ba_data/python/ba/_server.py +++ b/assets/src/ba_data/python/ba/_server.py @@ -60,7 +60,6 @@ class ServerController: """ def __init__(self, config: ServerConfig) -> None: - print('Server()') self._config = config self._playlist_name = '__default__' @@ -102,7 +101,7 @@ class ServerController: 'with a signed in server account') if self._first_run: - print((('BallisticaCore headless ' + print((('BallisticaCore ' if app.headless_build else 'BallisticaCore ') + str(app.version) + ' (' + str(app.build_number) + ') entering server-mode ' + time.strftime('%c'))) diff --git a/assets/src/server/server.py b/assets/src/server/server.py index 021ef81e..08a0f566 100755 --- a/assets/src/server/server.py +++ b/assets/src/server/server.py @@ -43,6 +43,7 @@ from bacommon.serverutils import (ServerConfig, ServerCommand, if TYPE_CHECKING: from typing import Optional, List + from types import FrameType class App: @@ -62,31 +63,22 @@ class App: self._binary_path = self._get_binary_path() self._config = ServerConfig() - # Launch a thread to listen for input - # (in daemon mode so it won't prevent us from dying) - self._input_commands: List[str] = [] - thread = threading.Thread(target=self._read_input) - thread.daemon = True - thread.start() - # Print basic usage info in interactive mode. if sys.stdin.isatty(): - print('BallisticaCore Server wrapper starting up...') + print('BallisticaCore server manager starting up...') + + self._input_commands: List[str] = [] # The server-binary will get relaunched after this amount of time # (combats memory leaks or other cruft that has built up). self._restart_minutes = 360.0 + self._done = False self._process: Optional[subprocess.Popen[bytes]] = None self._process_launch_time: Optional[float] = None - # The standard python exit/quit help messages don't apply here - # so let's get rid of them. - del __builtins__.exit - del __builtins__.quit - def _get_binary_path(self) -> str: - """Locate the game binary we'll run.""" + """Locate the game binary that we'll use.""" if os.name == 'nt': test_paths = ['ballisticacore_headless.exe'] else: @@ -100,16 +92,47 @@ class App: """Read from stdin and queue results for the app to handle.""" while True: line = sys.stdin.readline() - print('read line', line) self._input_commands.append(line.strip()) - def run(self) -> None: + def run_interactive(self) -> None: """Run the app loop to completion.""" + import code + import signal - # We currently never stop until explicitly killed. - while True: + # Python will handle SIGINT for us (as KeyboardInterrupt) but we + # need to register a SIGTERM handler if we want a chance to clean + # up our child process when someone tells us to die. (and avoid + # zombie processes) + signal.signal(signal.SIGTERM, self._handle_term_signal) + + # Fire off a background thread to wrangle our server binaries. + bgthread = threading.Thread(target=self._bg_thread_main) + bgthread.start() + + # Now just sit in an interpreter. + try: + code.interact(banner='', exitmsg='') + except SystemExit: + # We get this from the builtin quit(), etc. + # Need to catch this so we can clean up, otherwise we'll be + # left in limbo with our BG thread still running. + pass + except BaseException as exc: + print('Got unexpected exception: ', exc) + + # Mark ourselves as shutting down and wait for bgthread to wrap up. + self._done = True + bgthread.join() + + def _bg_thread_main(self) -> None: + while not self._done: self._run_server_cycle() + def _handle_term_signal(self, sig: int, frame: FrameType) -> None: + """Handle signals (will always run in the main thread).""" + del sig, frame # Unused. + raise SystemExit() + def _run_server_cycle(self) -> None: """Bring up the server binary and run it until exit.""" @@ -118,8 +141,27 @@ class App: # Launch the binary and grab its stdin; # we'll use this to feed it commands. self._process_launch_time = time.time() - self._process = subprocess.Popen( - [self._binary_path, '-cfgdir', 'ba_root'], stdin=subprocess.PIPE) + + # We don't want our subprocess to respond to Ctrl-C; we want to handle + # that ourself. So we need to do a bit of magic to accomplish that. + args = [self._binary_path, '-cfgdir', 'ba_root'] + if sys.platform.startswith('win'): + # https://msdn.microsoft.com/en-us/library/windows/ + # desktop/ms684863(v=vs.85).aspx + # CREATE_NEW_PROCESS_GROUP=0x00000200 -> If this flag is + # specified, CTRL+C signals will be disabled + self._process = subprocess.Popen(args, + stdin=subprocess.PIPE, + creationflags=0x00000200) + else: + # Note: Python docs tell us preexec_fn is unsafe with threads. + # https://docs.python.org/3/library/subprocess.html + # Perhaps we should just give the ballistica binary itself an + # option to ignore interrupt signals. + self._process = subprocess.Popen( # pylint: disable=W1509 + args, + stdin=subprocess.PIPE, + preexec_fn=self._subprocess_pre_exec) # Set quit to True any time after launching the server # to gracefully quit it at the next clean opportunity @@ -136,6 +178,11 @@ class App: self._kill_process() raise + def _subprocess_pre_exec(self) -> None: + """To ignore CTRL+C signal in the new process.""" + import signal + signal.signal(signal.SIGINT, signal.SIG_IGN) + def _setup_process_config(self) -> None: """Write files that must exist at process launch.""" os.makedirs('ba_root', exist_ok=True) @@ -163,6 +210,11 @@ class App: # Now just sleep and run commands until the process exits. while True: + # If the app is trying to shut down, crap out of the subprocess. + if self._done: + self._kill_process() + return + # Pass along any commands that have come in through stdin. for incmd in self._input_commands: print('WOULD PASS ALONG COMMAND', incmd) @@ -181,7 +233,8 @@ class App: # Watch for the process exiting. code: Optional[int] = self._process.poll() if code is not None: - print('Server process exited with code ' + str(code)) + print(f'Server process exited with code {code}.') + time.sleep(1.0) # Keep things from moving too fast. self._process = None self._process_launch_time = None break @@ -204,7 +257,4 @@ class App: if __name__ == '__main__': - try: - App().run() - except KeyboardInterrupt: - pass + App().run_interactive()