Skip to content

Commit

Permalink
MultiServer, customserver, CI, Test: Fix problems in room hosting and…
Browse files Browse the repository at this point in the history
… test/simulate it (#3464)

* Test: add hosting simulation test

* WebHost: add weak typing to get_app()

* MultiServer: add typing to auto_saver_thread

* MultiServer: don't cancel task, properly end it

* customserver: stop auto-save thread from saving after shutdown

and make sure it stops, another potential memory leak

* MultiServer, customserver: make datapackage small again

* customserver: collect/finish room tasks

Hopefully fixes the memory leak we are seeing

* CI: test hosting

* Test: hosting: verify autohoster saves on Ctrl+C

* customserver: save when stopping via Ctrl+C
  • Loading branch information
black-sliver authored Jun 5, 2024
1 parent 911eba3 commit afb6d9c
Show file tree
Hide file tree
Showing 11 changed files with 828 additions and 19 deletions.
31 changes: 30 additions & 1 deletion .github/workflows/unittests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ on:
- '.github/workflows/unittests.yml'

jobs:
build:
unit:
runs-on: ${{ matrix.os }}
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}

Expand Down Expand Up @@ -60,3 +60,32 @@ jobs:
- name: Unittests
run: |
pytest -n auto
hosting:
runs-on: ${{ matrix.os }}
name: Test hosting with ${{ matrix.python.version }} on ${{ matrix.os }}

strategy:
matrix:
os:
- ubuntu-latest
python:
- {version: '3.11'} # current

steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
run: |
python -m venv venv
source venv/bin/activate
python -m pip install --upgrade pip
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Test hosting
run: |
source venv/bin/activate
export PYTHONPATH=$(pwd)
python test/hosting/__main__.py
18 changes: 12 additions & 6 deletions MultiServer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import argparse
import asyncio
import collections
import contextlib
import copy
import datetime
import functools
Expand Down Expand Up @@ -176,7 +177,7 @@ class Context:
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
non_hintable_names: typing.Dict[str, typing.Set[str]]
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
""" each sphere is { player: { location_id, ... } } """
logger: logging.Logger
Expand Down Expand Up @@ -231,7 +232,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo
self.embedded_blacklist = {"host", "port"}
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
self.auto_save_interval = 60 # in seconds
self.auto_saver_thread = None
self.auto_saver_thread: typing.Optional[threading.Thread] = None
self.save_dirty = False
self.tags = ['AP']
self.games: typing.Dict[int, str] = {}
Expand Down Expand Up @@ -268,6 +269,11 @@ def _load_game_data(self):
for world_name, world in worlds.AutoWorldRegister.world_types.items():
self.non_hintable_names[world_name] = world.hint_blacklist

for game_package in self.gamespackage.values():
# remove groups from data sent to clients
del game_package["item_name_groups"]
del game_package["location_name_groups"]

def _init_game_data(self):
for game_name, game_package in self.gamespackage.items():
if "checksum" in game_package:
Expand Down Expand Up @@ -1926,8 +1932,6 @@ def _cmd_status(self, tag: str = "") -> bool:
def _cmd_exit(self) -> bool:
"""Shutdown the server"""
self.ctx.server.ws_server.close()
if self.ctx.shutdown_task:
self.ctx.shutdown_task.cancel()
self.ctx.exit_event.set()
return True

Expand Down Expand Up @@ -2285,7 +2289,8 @@ def parse_args() -> argparse.Namespace:


async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(ctx.auto_shutdown)
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown)

def inactivity_shutdown():
ctx.server.ws_server.close()
Expand All @@ -2305,7 +2310,8 @@ def inactivity_shutdown():
if seconds < 0:
inactivity_shutdown()
else:
await asyncio.sleep(seconds)
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(ctx.exit_event.wait(), seconds)


def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":
Expand Down
5 changes: 4 additions & 1 deletion WebHost.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@
import Utils
import settings

if typing.TYPE_CHECKING:
from flask import Flask

Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
settings.no_gui = True
configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml'))


def get_app():
def get_app() -> "Flask":
from WebHostLib import register, cache, app as raw_app
from WebHostLib.models import db

Expand Down
59 changes: 48 additions & 11 deletions WebHostLib/customserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,17 +168,28 @@ def get_random_port():
def get_static_server_data() -> dict:
import worlds
data = {
"non_hintable_names": {},
"gamespackage": worlds.network_data_package["games"],
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
"non_hintable_names": {
world_name: world.hint_blacklist
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"gamespackage": {
world_name: {
key: value
for key, value in game_package.items()
if key not in ("item_name_groups", "location_name_groups")
}
for world_name, game_package in worlds.network_data_package["games"].items()
},
"item_name_groups": {
world_name: world.item_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"location_name_groups": {
world_name: world.location_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
}

for world_name, world in worlds.AutoWorldRegister.world_types.items():
data["non_hintable_names"][world_name] = world.hint_blacklist

return data


Expand Down Expand Up @@ -266,12 +277,15 @@ async def start_room(room_id):
ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
if ctx.saving:
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task

except (KeyboardInterrupt, SystemExit):
if ctx.saving:
ctx._save()
setattr(asyncio.current_task(), "save", None)
except Exception as e:
with db_session:
room = Room.get(id=room_id)
Expand All @@ -281,8 +295,12 @@ async def start_room(room_id):
else:
if ctx.saving:
ctx._save()
setattr(asyncio.current_task(), "save", None)
finally:
try:
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
with (db_session):
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
Expand All @@ -294,13 +312,32 @@ async def start_room(room_id):
rooms_shutting_down.put(room_id)

class Starter(threading.Thread):
_tasks: typing.List[asyncio.Future]

def __init__(self):
super().__init__()
self._tasks = []

def _done(self, task: asyncio.Future):
self._tasks.remove(task)
task.result()

def run(self):
while 1:
next_room = rooms_to_run.get(block=True, timeout=None)
asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
self._tasks.append(task)
task.add_done_callback(self._done)
logging.info(f"Starting room {next_room} on {name}.")

starter = Starter()
starter.daemon = True
starter.start()
loop.run_forever()
try:
loop.run_forever()
finally:
# save all tasks that want to be saved during shutdown
for task in asyncio.all_tasks(loop):
save: typing.Optional[typing.Callable[[], typing.Any]] = getattr(task, "save", None)
if save:
save()
Empty file added test/hosting/__init__.py
Empty file.
Loading

0 comments on commit afb6d9c

Please sign in to comment.