Wired up graceful server restarts and exits

This commit is contained in:
Eric Froemling 2020-05-03 00:53:14 -07:00
parent f6566bae27
commit 2cdea7f392
9 changed files with 361 additions and 352 deletions

View File

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

View File

@ -383,6 +383,7 @@
<w>curhashes</w>
<w>curstate</w>
<w>curtime</w>
<w>curtimestr</w>
<w>customizebrowser</w>
<w>cutscenes</w>
<w>cval</w>
@ -565,6 +566,7 @@
<w>excludepowerups</w>
<w>excludetypes</w>
<w>excstr</w>
<w>execcode</w>
<w>execlocals</w>
<w>executils</w>
<w>exhash</w>
@ -1323,6 +1325,7 @@
<w>playtypes</w>
<w>plines</w>
<w>plistlib</w>
<w>plistname</w>
<w>plpt</w>
<w>plst</w>
<w>plusbutton</w>
@ -1587,6 +1590,7 @@
<w>serverdialog</w>
<w>serverget</w>
<w>servermanager</w>
<w>servermode</w>
<w>serverput</w>
<w>serverutils</w>
<w>sessionclass</w>
@ -1614,6 +1618,7 @@
<w>shroom</w>
<w>shutil</w>
<w>simplesubclasses</w>
<w>sincelaunch</w>
<w>singledispatch</w>
<w>sirplus</w>
<w>sitebuiltins</w>
@ -1827,6 +1832,7 @@
<w>timeremaining</w>
<w>timestep</w>
<w>timestring</w>
<w>timestrval</w>
<w>timetype</w>
<w>tipstext</w>
<w>titletext</w>

View File

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

View File

@ -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: $^

View File

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

View File

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

View File

@ -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__':

View File

@ -1,5 +1,5 @@
<!-- THIS FILE IS AUTO GENERATED; DO NOT EDIT BY HAND -->
<h4><em>last updated on 2020-05-02 for Ballistica version 1.5.0 build 20001</em></h4>
<h4><em>last updated on 2020-05-03 for Ballistica version 1.5.0 build 20001</em></h4>
<p>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 <a href="mailto:support@froemling.net">let me know</a>. Happy modding!</p>
<hr>
@ -3988,7 +3988,7 @@ cause the powerup box to make a sound and disappear or whatnot.</p>
</p>
<h3>Methods:</h3>
<h5><a href="#method_ba_ServerController____init__">&lt;constructor&gt;</a>, <a href="#method_ba_ServerController__handle_transition">handle_transition()</a>, <a href="#method_ba_ServerController__launch_server_session">launch_server_session()</a></h5>
<h5><a href="#method_ba_ServerController____init__">&lt;constructor&gt;</a>, <a href="#method_ba_ServerController__handle_transition">handle_transition()</a>, <a href="#method_ba_ServerController__shutdown">shutdown()</a></h5>
<dl>
<dt><h4><a name="method_ba_ServerController____init__">&lt;constructor&gt;</a></dt></h4><dd>
<p><span>ba.ServerController(config: ServerConfig)</span></p>
@ -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.</p>
</dd>
<dt><h4><a name="method_ba_ServerController__launch_server_session">launch_server_session()</a></dt></h4><dd>
<p><span>launch_server_session(self) -&gt; None</span></p>
<dt><h4><a name="method_ba_ServerController__shutdown">shutdown()</a></dt></h4><dd>
<p><span>shutdown(self, reason: ShutdownReason, immediate: bool) -&gt; None</span></p>
<p>Kick off a host-session based on the current server config.</p>
<p>Set the app to quit either now or at the next clean opportunity.</p>
</dd>
</dl>

View File

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