From 2cdea7f392966a600c575d766ed02fa32cee101b Mon Sep 17 00:00:00 2001
From: Eric Froemling
Date: Sun, 3 May 2020 00:53:14 -0700
Subject: [PATCH] Wired up graceful server restarts and exits
---
.efrocachemap | 8 +-
.idea/dictionaries/ericf.xml | 6 +
assets/.asset_manifest_public.json | 4 +-
assets/Makefile | 14 +-
assets/src/ba_data/python/ba/__init__.py | 2 +-
.../python/ba/{_server.py => _servermode.py} | 347 +++++++++---------
assets/src/server/ballisticacore_server.py | 259 +++++++------
docs/ba_module.md | 10 +-
tools/bacommon/servermanager.py | 63 +---
9 files changed, 361 insertions(+), 352 deletions(-)
rename assets/src/ba_data/python/ba/{_server.py => _servermode.py} (68%)
diff --git a/.efrocachemap b/.efrocachemap
index 365841f5..0e4afd8a 100644
--- a/.efrocachemap
+++ b/.efrocachemap
@@ -420,8 +420,8 @@
"assets/build/ba_data/audio/zoeOw.ogg": "https://files.ballistica.net/cache/ba1/75/1d/868bb04cf691736035c917d02762",
"assets/build/ba_data/audio/zoePickup01.ogg": "https://files.ballistica.net/cache/ba1/44/2a/8535b446284235cb503947ece074",
"assets/build/ba_data/audio/zoeScream01.ogg": "https://files.ballistica.net/cache/ba1/f5/d3/8e941851c4310465646c4167afc1",
- "assets/build/ba_data/data/langdata.json": "https://files.ballistica.net/cache/ba1/57/71/1a5fdd872252e5f21c3d3f7f4e29",
- "assets/build/ba_data/data/languages/arabic.json": "https://files.ballistica.net/cache/ba1/d1/bd/924adf7035ab6da8b2589f71b909",
+ "assets/build/ba_data/data/langdata.json": "https://files.ballistica.net/cache/ba1/73/be/02615076b8cf6a6fb052cf26ba7e",
+ "assets/build/ba_data/data/languages/arabic.json": "https://files.ballistica.net/cache/ba1/a7/87/0cf7ff45e545742202d882f0eefc",
"assets/build/ba_data/data/languages/belarussian.json": "https://files.ballistica.net/cache/ba1/49/5f/b29bb65369040892fe6601801637",
"assets/build/ba_data/data/languages/chinese.json": "https://files.ballistica.net/cache/ba1/53/2e/850d71d7ed654a3741527a31dc8d",
"assets/build/ba_data/data/languages/chinesetraditional.json": "https://files.ballistica.net/cache/ba1/aa/91/2411c0728bae33619c21237a2689",
@@ -429,11 +429,11 @@
"assets/build/ba_data/data/languages/czech.json": "https://files.ballistica.net/cache/ba1/f2/90/62968ad28a2499a8d182a5740a85",
"assets/build/ba_data/data/languages/danish.json": "https://files.ballistica.net/cache/ba1/3f/46/e4da3c1d2b0ebf916df55c608b28",
"assets/build/ba_data/data/languages/dutch.json": "https://files.ballistica.net/cache/ba1/86/33/8400929a710ae4a90f3f7cb57518",
- "assets/build/ba_data/data/languages/english.json": "https://files.ballistica.net/cache/ba1/41/be/aae8073e1bfc8aa378f8708d4a96",
+ "assets/build/ba_data/data/languages/english.json": "https://files.ballistica.net/cache/ba1/a2/ef/1c2207471cb58efc805115b31468",
"assets/build/ba_data/data/languages/esperanto.json": "https://files.ballistica.net/cache/ba1/6e/fd/685a4e1da031474d47a1d9eb2731",
"assets/build/ba_data/data/languages/french.json": "https://files.ballistica.net/cache/ba1/b4/35/4860ac0f2f30881221b5545560ce",
"assets/build/ba_data/data/languages/german.json": "https://files.ballistica.net/cache/ba1/9d/00/a8c4ef9f0a25e789c046bd741203",
- "assets/build/ba_data/data/languages/gibberish.json": "https://files.ballistica.net/cache/ba1/79/ac/38415932002fffdfe22639fd87f1",
+ "assets/build/ba_data/data/languages/gibberish.json": "https://files.ballistica.net/cache/ba1/8f/6a/32ef6c0141abace80b812c0b334b",
"assets/build/ba_data/data/languages/greek.json": "https://files.ballistica.net/cache/ba1/17/78/3fd0dca40e632ce53d03a944e7fa",
"assets/build/ba_data/data/languages/hindi.json": "https://files.ballistica.net/cache/ba1/7a/64/04464dc6ee8a45632857fa436bff",
"assets/build/ba_data/data/languages/hungarian.json": "https://files.ballistica.net/cache/ba1/4d/4b/0790110201c9adb1b521e9a55e63",
diff --git a/.idea/dictionaries/ericf.xml b/.idea/dictionaries/ericf.xml
index e805fcfa..8092af4e 100644
--- a/.idea/dictionaries/ericf.xml
+++ b/.idea/dictionaries/ericf.xml
@@ -383,6 +383,7 @@
curhashes
curstate
curtime
+ curtimestr
customizebrowser
cutscenes
cval
@@ -565,6 +566,7 @@
excludepowerups
excludetypes
excstr
+ execcode
execlocals
executils
exhash
@@ -1323,6 +1325,7 @@
playtypes
plines
plistlib
+ plistname
plpt
plst
plusbutton
@@ -1587,6 +1590,7 @@
serverdialog
serverget
servermanager
+ servermode
serverput
serverutils
sessionclass
@@ -1614,6 +1618,7 @@
shroom
shutil
simplesubclasses
+ sincelaunch
singledispatch
sirplus
sitebuiltins
@@ -1827,6 +1832,7 @@
timeremaining
timestep
timestring
+ timestrval
timetype
tipstext
titletext
diff --git a/assets/.asset_manifest_public.json b/assets/.asset_manifest_public.json
index 6cc7b59f..c89807a6 100644
--- a/assets/.asset_manifest_public.json
+++ b/assets/.asset_manifest_public.json
@@ -41,7 +41,7 @@
"ba_data/python/ba/__pycache__/_playlist.cpython-37.opt-1.pyc",
"ba_data/python/ba/__pycache__/_powerup.cpython-37.opt-1.pyc",
"ba_data/python/ba/__pycache__/_profile.cpython-37.opt-1.pyc",
- "ba_data/python/ba/__pycache__/_server.cpython-37.opt-1.pyc",
+ "ba_data/python/ba/__pycache__/_servermode.cpython-37.opt-1.pyc",
"ba_data/python/ba/__pycache__/_session.cpython-37.opt-1.pyc",
"ba_data/python/ba/__pycache__/_stats.cpython-37.opt-1.pyc",
"ba_data/python/ba/__pycache__/_store.cpython-37.opt-1.pyc",
@@ -93,7 +93,7 @@
"ba_data/python/ba/_playlist.py",
"ba_data/python/ba/_powerup.py",
"ba_data/python/ba/_profile.py",
- "ba_data/python/ba/_server.py",
+ "ba_data/python/ba/_servermode.py",
"ba_data/python/ba/_session.py",
"ba_data/python/ba/_stats.py",
"ba_data/python/ba/_store.py",
diff --git a/assets/Makefile b/assets/Makefile
index 98acb2c1..9a60dc2c 100644
--- a/assets/Makefile
+++ b/assets/Makefile
@@ -153,6 +153,7 @@ SCRIPT_TARGETS_PY_PUBLIC = \
build/ba_data/python/ba/_coopgame.py \
build/ba_data/python/ba/_meta.py \
build/ba_data/python/ba/_math.py \
+ build/ba_data/python/ba/_servermode.py \
build/ba_data/python/ba/_appconfig.py \
build/ba_data/python/ba/_gameresults.py \
build/ba_data/python/ba/_profile.py \
@@ -190,7 +191,6 @@ SCRIPT_TARGETS_PY_PUBLIC = \
build/ba_data/python/ba/_level.py \
build/ba_data/python/ba/_dependency.py \
build/ba_data/python/ba/_general.py \
- build/ba_data/python/ba/_server.py \
build/ba_data/python/ba/_account.py \
build/ba_data/python/ba/_music.py \
build/ba_data/python/ba/_lang.py \
@@ -378,6 +378,7 @@ SCRIPT_TARGETS_PYC_PUBLIC = \
build/ba_data/python/ba/__pycache__/_coopgame.cpython-37.opt-1.pyc \
build/ba_data/python/ba/__pycache__/_meta.cpython-37.opt-1.pyc \
build/ba_data/python/ba/__pycache__/_math.cpython-37.opt-1.pyc \
+ build/ba_data/python/ba/__pycache__/_servermode.cpython-37.opt-1.pyc \
build/ba_data/python/ba/__pycache__/_appconfig.cpython-37.opt-1.pyc \
build/ba_data/python/ba/__pycache__/_gameresults.cpython-37.opt-1.pyc \
build/ba_data/python/ba/__pycache__/_profile.cpython-37.opt-1.pyc \
@@ -415,7 +416,6 @@ SCRIPT_TARGETS_PYC_PUBLIC = \
build/ba_data/python/ba/__pycache__/_level.cpython-37.opt-1.pyc \
build/ba_data/python/ba/__pycache__/_dependency.cpython-37.opt-1.pyc \
build/ba_data/python/ba/__pycache__/_general.cpython-37.opt-1.pyc \
- build/ba_data/python/ba/__pycache__/_server.cpython-37.opt-1.pyc \
build/ba_data/python/ba/__pycache__/_account.cpython-37.opt-1.pyc \
build/ba_data/python/ba/__pycache__/_music.cpython-37.opt-1.pyc \
build/ba_data/python/ba/__pycache__/_lang.cpython-37.opt-1.pyc \
@@ -659,6 +659,11 @@ build/ba_data/python/ba/__pycache__/_math.cpython-37.opt-1.pyc: \
@echo Compiling script: $^
@rm -rf $@ && $(TOOLS_DIR)/snippets compile_python_files $^ && chmod 444 $@
+build/ba_data/python/ba/__pycache__/_servermode.cpython-37.opt-1.pyc: \
+ build/ba_data/python/ba/_servermode.py
+ @echo Compiling script: $^
+ @rm -rf $@ && $(TOOLS_DIR)/snippets compile_python_files $^ && chmod 444 $@
+
build/ba_data/python/ba/__pycache__/_appconfig.cpython-37.opt-1.pyc: \
build/ba_data/python/ba/_appconfig.py
@echo Compiling script: $^
@@ -844,11 +849,6 @@ build/ba_data/python/ba/__pycache__/_general.cpython-37.opt-1.pyc: \
@echo Compiling script: $^
@rm -rf $@ && $(TOOLS_DIR)/snippets compile_python_files $^ && chmod 444 $@
-build/ba_data/python/ba/__pycache__/_server.cpython-37.opt-1.pyc: \
- build/ba_data/python/ba/_server.py
- @echo Compiling script: $^
- @rm -rf $@ && $(TOOLS_DIR)/snippets compile_python_files $^ && chmod 444 $@
-
build/ba_data/python/ba/__pycache__/_account.cpython-37.opt-1.pyc: \
build/ba_data/python/ba/_account.py
@echo Compiling script: $^
diff --git a/assets/src/ba_data/python/ba/__init__.py b/assets/src/ba_data/python/ba/__init__.py
index e4b941fe..887e2415 100644
--- a/assets/src/ba_data/python/ba/__init__.py
+++ b/assets/src/ba_data/python/ba/__init__.py
@@ -59,7 +59,7 @@ from ba._gameresults import TeamGameResults
from ba._lang import Lstr, setlanguage, get_valid_languages
from ba._map import Map, getmaps
from ba._session import Session
-from ba._server import ServerController
+from ba._servermode import ServerController
from ba._stats import PlayerScoredMessage, PlayerRecord, Stats
from ba._team import Team
from ba._teamgame import TeamGameActivity
diff --git a/assets/src/ba_data/python/ba/_server.py b/assets/src/ba_data/python/ba/_servermode.py
similarity index 68%
rename from assets/src/ba_data/python/ba/_server.py
rename to assets/src/ba_data/python/ba/_servermode.py
index f0b49ed0..dfbe93c9 100644
--- a/assets/src/ba_data/python/ba/_server.py
+++ b/assets/src/ba_data/python/ba/_servermode.py
@@ -29,29 +29,35 @@ from efro.terminal import Clr
from ba._enums import TimeType
from ba._freeforallsession import FreeForAllSession
from ba._dualteamsession import DualTeamSession
-from bacommon.servermanager import ServerConfig, ServerCommand
+from bacommon.servermanager import (ServerCommand, StartServerModeCommand,
+ ShutdownCommand, ShutdownReason)
import _ba
if TYPE_CHECKING:
from typing import Optional, Dict, Any, Type
import ba
+ from bacommon.servermanager import ServerConfig
def _cmd(command_data: bytes) -> None:
- """Handle commands coming in from the server manager."""
+ """Handle commands coming in from our server manager parent process."""
import pickle
- command, payload = pickle.loads(command_data)
+ command = pickle.loads(command_data)
assert isinstance(command, ServerCommand)
- # We expect to receive a config command to kick things off.
- if command is ServerCommand.CONFIG:
- assert isinstance(payload, ServerConfig)
+ if isinstance(command, StartServerModeCommand):
assert _ba.app.server is None
- _ba.app.server = ServerController(payload)
+ _ba.app.server = ServerController(command.config)
return
- assert _ba.app.server is not None
- print('WOULD DO OTHER SERVER COMMAND')
+ if isinstance(command, ShutdownCommand):
+ assert _ba.app.server is not None
+ _ba.app.server.shutdown(reason=command.reason,
+ immediate=command.immediate)
+ return
+
+ print(f'{Clr.SRED}ERROR: server process'
+ f' got unknown command: {type(command)}{Clr.RST}')
class ServerController:
@@ -68,6 +74,8 @@ class ServerController:
self._run_server_wait_timer: Optional[ba.Timer] = None
self._next_stuck_login_warn_time = time.time() + 10.0
self._first_run = True
+ self._shutdown_reason: Optional[ShutdownReason] = None
+ self._executing_shutdown = False
# Make note if they want us to import a playlist;
# we'll need to do that first if so.
@@ -76,59 +84,25 @@ class ServerController:
self._playlist_fetch_got_response = False
self._playlist_fetch_code = -1
- self._config_server()
-
- # Now sit around until we're signed in and then
- # kick off the server.
+ # Now sit around doing any pre-launch prep such as waiting for
+ # account sign-in or fetching playlists; this will kick off the
+ # session once done.
with _ba.Context('ui'):
- self._run_server_wait_timer = _ba.Timer(
- 0.25,
- self._update_server_playlist_fetch,
- timetype=TimeType.REAL,
- repeat=True)
+ self._run_server_wait_timer = _ba.Timer(0.25,
+ self._prepare_to_serve,
+ timetype=TimeType.REAL,
+ repeat=True)
- def launch_server_session(self) -> None:
- """Kick off a host-session based on the current server config."""
- app = _ba.app
- appcfg = app.config
-
- sessiontype = self._get_session_type()
-
- if _ba.get_account_state() != 'signed_in':
- print('WARNING: launch_server_session() expects to run '
- 'with a signed in server account')
-
- if self._first_run:
- print((('BallisticaCore '
- if app.headless_build else 'BallisticaCore ') +
- str(app.version) + ' (' + str(app.build_number) +
- ') entering server-mode ' + time.strftime('%c')))
-
- appcfg['Show Tutorial'] = False
-
- if sessiontype is FreeForAllSession:
- appcfg['Free-for-All Playlist Selection'] = self._playlist_name
- appcfg['Free-for-All Playlist Randomize'] = (
- self._config.playlist_shuffle)
- elif sessiontype is DualTeamSession:
- appcfg['Team Tournament Playlist Selection'] = self._playlist_name
- appcfg['Team Tournament Playlist Randomize'] = (
- self._config.playlist_shuffle)
+ def shutdown(self, reason: ShutdownReason, immediate: bool) -> None:
+ """Set the app to quit either now or at the next clean opportunity."""
+ self._shutdown_reason = reason
+ if immediate:
+ print(f'{Clr.SBLU}Immediate shutdown initiated.{Clr.RST}')
+ self._execute_shutdown()
else:
- raise RuntimeError(f'Unknown session type {sessiontype}')
-
- appcfg['Port'] = self._config.port
-
- # Set series lengths.
- app.teams_series_length = self._config.teams_series_length
- app.ffa_series_length = self._config.ffa_series_length
-
- # And here we go.
- _ba.new_host_session(sessiontype)
-
- if not self._ran_access_check:
- self._run_access_check()
- self._ran_access_check = True
+ print(f'{Clr.SBLU}Shutdown initiated;'
+ f' server process will exit at the next clean opportunity.'
+ f'{Clr.RST}')
def handle_transition(self) -> bool:
"""Handle transitioning to a new ba.Session or quitting the app.
@@ -138,121 +112,33 @@ class ServerController:
Should return True if action will be handled by us; False if the
session should just continue on it's merry way.
"""
- print('FIXME: fill out server handle_transition()')
- # If the app is in server mode and this activity
- # if self._allow_server_transition and _ba.app.server_config_dirty:
- # from ba import _server
- # from ba._lang import Lstr
- # from ba._general import Call
- # from ba._enums import TimeType
- # if _ba.app.server_config.get('quit', False):
- # if not self._kicked_off_server_shutdown:
- # if _ba.app.server_config.get(
- # 'quit_reason') == 'restarting':
- # # FIXME: Should add a server-screen-message call
- # # or something.
- # _ba.chat_message(
- # Lstr(resource='internal.serverRestartingText').
- # evaluate())
- # print(('Exiting for server-restart at ' +
- # time.strftime('%c')))
- # else:
- # print(('Exiting for server-shutdown at ' +
- # time.strftime('%c')))
- # with _ba.Context('ui'):
- # _ba.timer(2.0, _ba.quit, timetype=TimeType.REAL)
- # self._kicked_off_server_shutdown = True
- # return True
- # else:
- # if not self._kicked_off_server_restart:
- # print(('Running updated server config at ' +
- # time.strftime('%c')))
- # with _ba.Context('ui'):
- # _ba.timer(1.0,
- # Call(_ba.pushcall,
- # _server.launch_server_session),
- # timetype=TimeType.REAL)
- # self._kicked_off_server_restart = True
- # return True
+ if self._shutdown_reason is not None:
+ self._execute_shutdown()
+ return True
return False
- def _get_session_type(self) -> Type[ba.Session]:
-
- # Convert string session type to the class.
- # Hmm should we just keep this as a string?
- if self._config.session_type == 'ffa':
- return FreeForAllSession
- if self._config.session_type == 'teams':
- return DualTeamSession
- raise RuntimeError(
- f'Invalid session_type: "{self._config.session_type}"')
-
- def _update_server_playlist_fetch(self) -> None:
-
- signed_in = _ba.get_account_state() == 'signed_in'
-
- if not signed_in:
-
- # Signing in to the local server account should not take long;
- # complain if it does...
- curtime = time.time()
- if curtime > self._next_stuck_login_warn_time:
- print('Still waiting for account sign-in...')
- self._next_stuck_login_warn_time = curtime + 10.0
+ def _execute_shutdown(self) -> None:
+ from ba._lang import Lstr
+ if self._executing_shutdown:
+ return
+ self._executing_shutdown = True
+ timestrval = time.strftime('%c')
+ if self._shutdown_reason is ShutdownReason.RESTARTING:
+ # FIXME: Should add a server-screen-message call.
+ # (so we could send this an an Lstr)
+ _ba.chat_message(
+ Lstr(resource='internal.serverRestartingText').evaluate())
+ print(f'{Clr.SBLU}Exiting for server-restart'
+ f' at {timestrval}{Clr.RST}')
else:
- can_launch = False
-
- # If we're trying to fetch a playlist, we do that first.
- if self._playlist_fetch_running:
-
- # Send request if we haven't.
- if not self._playlist_fetch_sent_request:
-
- print(f'Requesting shared-playlist'
- f' {self._config.playlist_code}...')
-
- _ba.add_transaction(
- {
- 'type': 'IMPORT_PLAYLIST',
- 'code': str(self._config.playlist_code),
- 'overwrite': True
- },
- callback=self._on_playlist_fetch_response)
- _ba.run_transactions()
-
- self._playlist_fetch_sent_request = True
-
- # If we got a valid result, forget the fetch ever
- # existed and move on.
- if self._playlist_fetch_got_response:
- self._playlist_fetch_running = False
- can_launch = True
- else:
- can_launch = True
-
- if can_launch:
- self._run_server_wait_timer = None
- _ba.pushcall(self.launch_server_session)
-
- def _on_playlist_fetch_response(
- self,
- result: Optional[Dict[str, Any]],
- ) -> None:
- if result is None:
- print('Error fetching playlist;' ' aborting.')
- sys.exit(-1)
-
- # Once we get here we simply modify our
- # config to use this playlist.
- type_name = (
- 'teams' if result['playlistType'] == 'Team Tournament' else
- 'ffa' if result['playlistType'] == 'Free-for-All' else '??')
- print(('Playlist \'' + result['playlistName'] + '\' (' + type_name +
- ') downloaded; running...'))
-
- self._playlist_fetch_got_response = True
- self._config.session_type = type_name
- self._playlist_name = (result['playlistName'])
+ # FIXME: Should add a server-screen-message call.
+ # (so we could send this an an Lstr)
+ print(f'{Clr.SBLU}Exiting for server-shutdown'
+ f' at {timestrval}{Clr.RST}')
+ _ba.chat_message(
+ Lstr(resource='internal.serverShuttingDownText').evaluate())
+ with _ba.Context('ui'):
+ _ba.timer(2.0, _ba.quit, timetype=TimeType.REAL)
def _run_access_check(self) -> None:
"""Check with the master server to see if we're likely joinable."""
@@ -272,7 +158,7 @@ class ServerController:
print('error on UDP port access check (internet down?)')
else:
if data['accessible']:
- print(f'{Clr.SGRN}UDP port {gameport} access check successful.'
+ print(f'{Clr.SBLU}UDP port {gameport} access check successful.'
f' Your server appears to be joinable from the'
f' internet.{Clr.RST}')
else:
@@ -280,18 +166,123 @@ class ServerController:
f' Your server does not appear to be joinable'
f' from the internet.{Clr.RST}')
- def _config_server(self) -> None:
- """Apply server config changes that can take effect immediately.
+ def _prepare_to_serve(self) -> None:
- (party name, etc)
- """
+ signed_in = _ba.get_account_state() == 'signed_in'
- _ba.app.config['Auto Balance Teams'] = (
- self._config.auto_balance_teams)
+ if not signed_in:
+ # Signing in to the local server account should not take long;
+ # complain if it does...
+ curtime = time.time()
+ if curtime > self._next_stuck_login_warn_time:
+ print('Still waiting for account sign-in...')
+ self._next_stuck_login_warn_time = curtime + 10.0
+ else:
+ can_launch = False
+
+ # If we're trying to fetch a playlist, we do that first.
+ if self._playlist_fetch_running:
+
+ # Send request if we haven't.
+ if not self._playlist_fetch_sent_request:
+ print(f'{Clr.SBLU}Requesting shared-playlist'
+ f' {self._config.playlist_code}...{Clr.RST}')
+ _ba.add_transaction(
+ {
+ 'type': 'IMPORT_PLAYLIST',
+ 'code': str(self._config.playlist_code),
+ 'overwrite': True
+ },
+ callback=self._on_playlist_fetch_response)
+ _ba.run_transactions()
+ self._playlist_fetch_sent_request = True
+
+ # If we got a valid result, forget the fetch ever
+ # existed and move on.
+ if self._playlist_fetch_got_response:
+ self._playlist_fetch_running = False
+ can_launch = True
+ else:
+ can_launch = True
+
+ if can_launch:
+ self._run_server_wait_timer = None
+ _ba.pushcall(self._launch_server_session)
+
+ def _on_playlist_fetch_response(
+ self,
+ result: Optional[Dict[str, Any]],
+ ) -> None:
+ if result is None:
+ print('Error fetching playlist;' ' aborting.')
+ sys.exit(-1)
+
+ # Once we get here we simply modify our
+ # config to use this playlist.
+ typename = (
+ 'teams' if result['playlistType'] == 'Team Tournament' else
+ 'ffa' if result['playlistType'] == 'Free-for-All' else '??')
+ plistname = result['playlistName']
+ print(f'{Clr.SBLU}Got playlist: "{plistname}" ({typename}).{Clr.RST}')
+
+ self._playlist_fetch_got_response = True
+ self._config.session_type = typename
+ self._playlist_name = (result['playlistName'])
+
+ def _get_session_type(self) -> Type[ba.Session]:
+ # Convert string session type to the class.
+ # Hmm should we just keep this as a string?
+ if self._config.session_type == 'ffa':
+ return FreeForAllSession
+ if self._config.session_type == 'teams':
+ return DualTeamSession
+ raise RuntimeError(
+ f'Invalid session_type: "{self._config.session_type}"')
+
+ def _launch_server_session(self) -> None:
+ """Kick off a host-session based on the current server config."""
+ app = _ba.app
+ appcfg = app.config
+ sessiontype = self._get_session_type()
+
+ if _ba.get_account_state() != 'signed_in':
+ print('WARNING: launch_server_session() expects to run '
+ 'with a signed in server account')
+
+ if self._first_run:
+ curtimestr = time.strftime('%c')
+ print(f'{Clr.SBLU}BallisticaCore {app.version}'
+ f' ({app.build_number})'
+ f' entering server-mode {curtimestr}{Clr.RST}')
+
+ appcfg['Show Tutorial'] = False
+
+ if sessiontype is FreeForAllSession:
+ appcfg['Free-for-All Playlist Selection'] = self._playlist_name
+ appcfg['Free-for-All Playlist Randomize'] = (
+ self._config.playlist_shuffle)
+ elif sessiontype is DualTeamSession:
+ appcfg['Team Tournament Playlist Selection'] = self._playlist_name
+ appcfg['Team Tournament Playlist Randomize'] = (
+ self._config.playlist_shuffle)
+ else:
+ raise RuntimeError(f'Unknown session type {sessiontype}')
+
+ app.teams_series_length = self._config.teams_series_length
+ app.ffa_series_length = self._config.ffa_series_length
+ appcfg['Port'] = self._config.port
+ appcfg['Auto Balance Teams'] = self._config.auto_balance_teams
_ba.set_public_party_max_size(self._config.max_party_size)
_ba.set_public_party_name(self._config.party_name)
_ba.set_public_party_stats_url(self._config.stats_url)
# Call set-enabled last (will push state to the cloud).
_ba.set_public_party_enabled(self._config.party_is_public)
+
+ # And here we go.
+ _ba.new_host_session(sessiontype)
+
+ if not self._ran_access_check:
+ self._run_access_check()
+ self._ran_access_check = True
diff --git a/assets/src/server/ballisticacore_server.py b/assets/src/server/ballisticacore_server.py
index 56503102..42dcc482 100755
--- a/assets/src/server/ballisticacore_server.py
+++ b/assets/src/server/ballisticacore_server.py
@@ -40,12 +40,12 @@ sys.path += [
from efro.terminal import Clr
from efro.dataclassutils import dataclass_assign, dataclass_validate
-from bacommon.servermanager import (ServerConfig, ServerCommand,
- make_server_command)
+from bacommon.servermanager import (ServerConfig, StartServerModeCommand)
if TYPE_CHECKING:
- from typing import Optional, List, Dict
+ from typing import Optional, List, Dict, Union
from types import FrameType
+ from bacommon.servermanager import ServerCommand
class ServerManagerApp:
@@ -58,12 +58,13 @@ class ServerManagerApp:
def __init__(self) -> None:
self._config = self._load_config()
self._done = False
- self._process_commands: List[str] = []
+ self._process_commands: List[Union[str, ServerCommand]] = []
self._process_commands_lock = Lock()
self._restart_minutes: Optional[float] = 360.0
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
@property
@@ -85,6 +86,95 @@ class ServerManagerApp:
"""
return self._restart_minutes
+ def run_interactive(self) -> None:
+ """Run the app loop to completion."""
+ import code
+ import signal
+
+ if self._running_interactive:
+ raise RuntimeError('Already running interactively.')
+ self._running_interactive = True
+
+ # Print basic usage info in interactive mode.
+ if sys.stdin.isatty():
+ print(f'{Clr.SMAG}BallisticaCore server manager starting up...\n'
+ f'Use the "mgr" object to make live server adjustments.\n'
+ f'Type "help(mgr)" for more information.{Clr.RST}')
+
+ # Python will handle SIGINT for us (as KeyboardInterrupt) but we
+ # need to register a SIGTERM handler so we have 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.
+ self._process_thread = Thread(target=self._bg_thread_main)
+ self._process_thread.start()
+
+ # According to Python docs, default locals dict has __name__ set
+ # to __console__ and __doc__ set to None; using that as start point.
+ # https://docs.python.org/3/library/code.html
+ locs = {'__name__': '__console__', '__doc__': None, 'mgr': self}
+
+ # Enable tab-completion if possible.
+ self._enable_tab_completion(locs)
+
+ # Now just sit in an interpreter.
+ # TODO: make it possible to use IPython if the user has it available.
+ try:
+ code.interact(local=locs, 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(f'{Clr.SRED}Unexpected interpreter exception:'
+ f' {exc}{Clr.RST}')
+
+ # Mark ourselves as shutting down and wait for the process to wrap up.
+ self._done = True
+ self._process_thread.join()
+
+ def cmd(self, statement: str) -> None:
+ """Exec a Python command on the current running server child-process.
+
+ Note that commands are executed asynchronously and no status or
+ return value is accessible from this manager app.
+ """
+ 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)
+
+ # Ideally we'd block here until the command was run so our prompt would
+ # print after it's results. We currently don't get any response from
+ # the app so the best we can do is block until our bg thread has sent
+ # 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:
+ break
+ time.sleep(0.1)
+
+ # One last short delay so if we come out *just* as the command is sent
+ # we'll hopefully still give it enough time to process/print.
+ time.sleep(0.1)
+
+ def restart(self, immediate: bool = False) -> None:
+ """Restart the server child-process.
+
+ This can be necessary for some config changes to take effect.
+ By default, the server will restart at the next good transition
+ point (end of a series, etc) but passing immediate=True will restart
+ it immediately.
+ """
+ from bacommon.servermanager import ShutdownCommand, ShutdownReason
+ self._enqueue_server_command(
+ ShutdownCommand(reason=ShutdownReason.RESTARTING,
+ immediate=immediate))
+
def _load_config(self) -> ServerConfig:
user_config_path = 'config.yaml'
@@ -113,85 +203,6 @@ class ServerManagerApp:
# This is expected (readline doesn't exist under windows).
pass
- def run_interactive(self) -> None:
- """Run the app loop to completion."""
- import code
- import signal
-
- if self._running_interactive:
- raise RuntimeError('Already running interactively.')
- self._running_interactive = True
-
- # Print basic usage info in interactive mode.
- if sys.stdin.isatty():
- print(f'{Clr.SBLU}BallisticaCore server manager starting up...\n'
- f'Use the "mgr" object to make live server adjustments.\n'
- f'Type "help(mgr)" for more information.{Clr.RST}')
-
- # Python will handle SIGINT for us (as KeyboardInterrupt) but we
- # need to register a SIGTERM handler so we have 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.
- self._process_thread = Thread(target=self._bg_thread_main)
- self._process_thread.start()
-
- # According to Python docs, default locals dict has __name__ set
- # to __console__ and __doc__ set to None; using that as start point.
- # https://docs.python.org/3/library/code.html
- locs = {'__name__': '__console__', '__doc__': None, 'mgr': self}
-
- # Enable tab-completion if possible.
- self._enable_tab_completion(locs)
-
- # Give ourself a lovely color prompt.
- sys.ps1 = f'{Clr.SGRN}>>> {Clr.RST}'
- sys.ps2 = f'{Clr.SGRN}... {Clr.RST}'
-
- # Now just sit in an interpreter.
- # TODO: make it possible to use IPython if the user has it available.
- try:
- code.interact(local=locs, 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 the process to wrap up.
- self._done = True
- self._process_thread.join()
-
- def cmd(self, statement: str) -> None:
- """Exec a Python command on the current running server binary.
-
- Note that commands are executed asynchronously and no status or
- return value is accessible from this manager app.
- """
- 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)
-
- # Ideally we'd block here until the command was run so our prompt would
- # print after it's results. We currently don't get any response from
- # the app so the best we can do is block until our bg thread has sent
- # 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:
- break
- time.sleep(0.1)
-
- # One last short delay so if we come out *just* as the command is sent
- # we'll hopefully still give it enough time to process/print.
- time.sleep(0.1)
-
def _bg_thread_main(self) -> None:
"""Top level method run by our bg thread."""
while not self._done:
@@ -203,7 +214,7 @@ class ServerManagerApp:
raise SystemExit()
def _run_server_cycle(self) -> None:
- """Spin up the server process and run it until exit."""
+ """Spin up the server child-process and run it until exit."""
self._prep_process_environment()
@@ -216,18 +227,13 @@ class ServerManagerApp:
# slight behavior tweaks. Hmm; should this be an argument instead?
os.environ['BA_SERVER_WRAPPER_MANAGED'] = '1'
+ print(f'{Clr.SMAG}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')
- # Set quit to True any time after launching the server
- # to gracefully quit it at the next clean opportunity
- # (the end of the current series, etc).
- self._config.quit = False
- self._config.quit_reason = None
-
# Do the thing.
# No matter how this ends up, make sure the process is dead after.
try:
@@ -250,16 +256,37 @@ class ServerManagerApp:
with open('dist/ba_root/config.json', 'w') as outfile:
outfile.write(json.dumps(bincfg))
+ def _enqueue_server_command(self, command: ServerCommand) -> None:
+ """Enqueue a command to be sent to the server.
+
+ Can be called from any thread.
+ """
+ with self._process_commands_lock:
+ self._process_commands.append(command)
+
+ def _send_server_command(self, command: ServerCommand) -> None:
+ """Send a command to the server.
+
+ 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
+ val = repr(pickle.dumps(command))
+ assert '\n' not in val
+ execcode = f'import ba._server; ba._server._cmd({val})\n'.encode()
+ self._process.stdin.write(execcode)
+ self._process.stdin.flush()
+
def _run_process_until_exit(self) -> None:
assert self._process is not None
+ assert self._process.stdin is not None
# Send the initial server config which should kick things off.
# (but make sure its values are still valid first)
dataclass_validate(self._config)
- cmd = make_server_command(ServerCommand.CONFIG, self._config)
- assert self._process.stdin is not None
- self._process.stdin.write(cmd)
- self._process.stdin.flush()
+ self._send_server_command(StartServerModeCommand(self._config))
while True:
@@ -270,40 +297,50 @@ class ServerManagerApp:
# Pass along any commands to our process.
with self._process_commands_lock:
for incmd in self._process_commands:
- # We're passing a raw string to exec; no need to wrap it
+ # If we're passing a raw string to exec, no need to wrap it
# in any proper structure.
- self._process.stdin.write((incmd + '\n').encode())
- self._process.stdin.flush()
+ if isinstance(incmd, str):
+ self._process.stdin.write((incmd + '\n').encode())
+ self._process.stdin.flush()
+ else:
+ self._send_server_command(incmd)
self._process_commands = []
- # Request a restart after a while.
+ # Request a soft restart after a while.
assert self._process_launch_time is not None
- if (self._restart_minutes is not None
- and time.time() - self._process_launch_time >
- (self._restart_minutes * 60.0) and not self._config.quit):
- print('restart_minutes (' + str(self._restart_minutes) +
- 'm) elapsed; will restart server process '
- 'at next clean opportunity...')
- self._config.quit = True
- self._config.quit_reason = 'restarting'
+ sincelaunch = time.time() - self._process_launch_time
+ if (self._restart_minutes is not None and sincelaunch >
+ (self._restart_minutes * 60.0)
+ and not self._process_sent_auto_restart):
+ print(f'{Clr.SMAG}restart_minutes ({self._restart_minutes})'
+ f' elapsed; requesting child-process'
+ f' soft restart...{Clr.RST}')
+ self.restart()
+ self._process_sent_auto_restart = True
# Watch for the process exiting.
code: Optional[int] = self._process.poll()
if code is not None:
- print(f'Server process exited with code {code}.')
+ print(f'{Clr.SMAG}Server process exited'
+ f' with code {code}.{Clr.RST}')
time.sleep(1.0) # Keep things from moving too fast.
- self._process = self._process_launch_time = None
+ self._reset_process_vars()
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 _kill_process(self) -> None:
"""End the server process if it still exists."""
assert current_thread() is self._process_thread
if self._process is None:
return
- print(f'{Clr.SBLU}Stopping server process...{Clr.RST}')
+ print(f'{Clr.SMAG}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.
@@ -312,8 +349,8 @@ class ServerManagerApp:
self._process.wait(timeout=10)
except subprocess.TimeoutExpired:
self._process.kill()
- self._process = self._process_launch_time = None
- print(f'{Clr.SBLU}Server process stopped.{Clr.RST}')
+ self._reset_process_vars()
+ print(f'{Clr.SMAG}Server process stopped.{Clr.RST}')
if __name__ == '__main__':
diff --git a/docs/ba_module.md b/docs/ba_module.md
index f17b98b5..c94cc09a 100644
--- a/docs/ba_module.md
+++ b/docs/ba_module.md
@@ -1,5 +1,5 @@
-last updated on 2020-05-02 for Ballistica version 1.5.0 build 20001
+last updated on 2020-05-03 for Ballistica version 1.5.0 build 20001
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!
@@ -3988,7 +3988,7 @@ cause the powerup box to make a sound and disappear or whatnot.
Methods:
-
+
-
ba.ServerController(config: ServerConfig)
@@ -4005,10 +4005,10 @@ Should return True if action will be handled by us; False if the
session should just continue on it's merry way.
--
-
launch_server_session(self) -> None
+ -
+
shutdown(self, reason: ShutdownReason, immediate: bool) -> None
-Kick off a host-session based on the current server config.
+Set the app to quit either now or at the next clean opportunity.
diff --git a/tools/bacommon/servermanager.py b/tools/bacommon/servermanager.py
index 0c30a2a3..ca89bf86 100644
--- a/tools/bacommon/servermanager.py
+++ b/tools/bacommon/servermanager.py
@@ -23,16 +23,15 @@ from __future__ import annotations
from enum import Enum
from dataclasses import dataclass
-from typing import TYPE_CHECKING, overload
+from typing import TYPE_CHECKING
if TYPE_CHECKING:
- from typing import Optional, Any, Tuple
- from typing_extensions import Literal
+ from typing import Optional
@dataclass
class ServerConfig:
- """Configuration for the server manager script."""
+ """Configuration for the server manager app (ballisticacore_server)."""
# Name of our server in the public parties list.
party_name: str = 'FFA'
@@ -109,52 +108,28 @@ class ServerConfig:
# http://bombsquadgame.com/accountquery?id=ACCOUNT_ID_HERE
stats_url: Optional[str] = None
- # FIXME REMOVE
- quit: bool = False
-
- # FIXME REMOVE
- quit_reason: Optional[str] = None
-
# NOTE: as much as possible, communication from the server-manager to the
-# child binary should go through this and not ad-hoc python string commands
+# child-process should go through these and not ad-hoc Python string commands
# since this way is type safe.
-class ServerCommand(Enum):
- """Command types that can be sent to the app in server-mode."""
- CONFIG = 'config'
- QUIT = 'quit'
+class ServerCommand:
+ """Base class for commands that can be sent to the server."""
-@overload
-def make_server_command(command: Literal[ServerCommand.CONFIG],
- payload: ServerConfig) -> bytes:
- """Overload for CONFIG commands."""
- ...
+@dataclass
+class StartServerModeCommand(ServerCommand):
+ """Tells the app to switch into 'server' mode."""
+ config: ServerConfig
-@overload
-def make_server_command(command: Literal[ServerCommand.QUIT],
- payload: int) -> bytes:
- """Overload for QUIT commands."""
- ...
+class ShutdownReason(Enum):
+ """Reason a server is shutting down."""
+ NONE = 'none'
+ RESTARTING = 'restarting'
-def make_server_command(command: ServerCommand, payload: Any) -> bytes:
- """Create a command that can be exec'ed on the server binary."""
- import pickle
-
- # Pickle this stuff down to bytes and wrap it in a command to
- # extract/run it on the other end.
- val = repr(pickle.dumps((command, payload)))
- assert '\n' not in val
- return f'import ba._server; ba._server._cmd({val})\n'.encode()
-
-
-def extract_server_command(cmd: str) -> Tuple[ServerCommand, Any]:
- """Given a server-command string, returns command objects."""
-
- # Yes, eval is unsafe and all that, but this is only intended
- # for communication between a parent and child process so we
- # can live with it here.
- print('would extract', cmd)
- return ServerCommand.CONFIG, None
+@dataclass
+class ShutdownCommand(ServerCommand):
+ """Tells the server to shut down."""
+ reason: ShutdownReason
+ immediate: bool