diff --git a/MultiServer.py b/MultiServer.py index 6e4638bd..87036196 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -23,6 +23,11 @@ ModuleUpdate.update() import websockets import colorama +try: + # ponyorm is a requirement for webhost, not default server, so may not be importable + from pony.orm.dbapiprovider import OperationalError +except ImportError: + OperationalError = ConnectionError import NetUtils from worlds.AutoWorld import AutoWorldRegister @@ -404,12 +409,16 @@ class Context: def save_regularly(): import time while not self.exit_event.is_set(): - time.sleep(self.auto_save_interval) - if self.save_dirty: - logging.debug("Saving via thread.") + try: + time.sleep(self.auto_save_interval) + if self.save_dirty: + logging.debug("Saving via thread.") + self._save() + except OperationalError as e: + logging.exception(e) + logging.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.") + else: self.save_dirty = False - self._save() - self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True) self.auto_saver_thread.start() diff --git a/WebHost.py b/WebHost.py index dc558a9a..9b2a8808 100644 --- a/WebHost.py +++ b/WebHost.py @@ -22,7 +22,7 @@ from WebHostLib.autolauncher import autohost, autogen from WebHostLib.lttpsprites import update_sprites_lttp from WebHostLib.options import create as create_options_files -from worlds.AutoWorld import AutoWorldRegister, WebWorld +from worlds.AutoWorld import AutoWorldRegister configpath = os.path.abspath("config.yaml") if not os.path.exists(configpath): # fall back to config.yaml in home @@ -75,7 +75,6 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] for guide in game_data['tutorials']: if guide and tutorial.tutorial_name == guide['name']: guide['files'].append(current_tutorial['files'][0]) - added = True break else: game_data['tutorials'].append(current_tutorial) @@ -109,7 +108,6 @@ if __name__ == "__main__": autogen(app.config) if app.config["SELFHOST"]: # using WSGI, you just want to run get_app() if app.config["DEBUG"]: - autohost(app.config) app.run(debug=True, port=app.config["PORT"]) else: serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"]) diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index 9bb1cb79..522bbb27 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -170,7 +170,9 @@ def _read_log(path: str): @app.route('/log/') def display_log(room: UUID): - return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8") + if room.owner == session["_id"]: + return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8") + return "Access Denied", 403 @app.route('/room/', methods=['GET', 'POST']) diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index dc88c202..522bbdd1 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -2,8 +2,9 @@ from __future__ import annotations import logging import json import multiprocessing +import threading from datetime import timedelta, datetime -import concurrent.futures + import sys import typing import time @@ -17,6 +18,7 @@ from Utils import restricted_loads class CommonLocker(): """Uses a file lock to signal that something is already running""" lock_folder = "file_locks" + def __init__(self, lockname: str, folder=None): if folder: self.lock_folder = folder @@ -110,6 +112,7 @@ def autohost(config: dict): def keep_running(): try: with Locker("autohost"): + run_guardian() while 1: time.sleep(0.1) with db_session: @@ -162,16 +165,15 @@ def autogen(config: dict): threading.Thread(target=keep_running, name="AP_Autogen").start() -multiworlds = {} - -guardians = concurrent.futures.ThreadPoolExecutor(2, thread_name_prefix="Guardian") +multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {} class MultiworldInstance(): def __init__(self, room: Room, config: dict): self.room_id = room.id self.process: typing.Optional[multiprocessing.Process] = None - multiworlds[self.room_id] = self + with guardian_lock: + multiworlds[self.room_id] = self self.ponyconfig = config["PONY"] def start(self): @@ -179,21 +181,48 @@ class MultiworldInstance(): return False logging.info(f"Spinning up {self.room_id}") - self.process = multiprocessing.Process(group=None, target=run_server_process, - args=(self.room_id, self.ponyconfig), - name="MultiHost") - self.process.start() - self.guardian = guardians.submit(self._collect) + process = multiprocessing.Process(group=None, target=run_server_process, + args=(self.room_id, self.ponyconfig), + name="MultiHost") + process.start() + # bind after start to prevent thread sync issues with guardian. + self.process = process def stop(self): if self.process: self.process.terminate() self.process = None - def _collect(self): + def done(self): + return self.process and not self.process.is_alive() + + def collect(self): self.process.join() # wait for process to finish self.process = None - self.guardian = None + + +guardian = None +guardian_lock = threading.Lock() + + +def run_guardian(): + global guardian + global multiworlds + with guardian_lock: + if not guardian: + def guard(): + while 1: + time.sleep(1) + done = [] + with guardian_lock: + for key, instance in multiworlds.items(): + if instance.done(): + instance.collect() + done.append(key) + for key in done: + del (multiworlds[key]) + + guardian = threading.Thread(name="Guardian", target=guard) from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index a91ee51e..f78a8eb2 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -1,7 +1,6 @@ from __future__ import annotations import functools -import logging import websockets import asyncio import socket @@ -9,6 +8,7 @@ import threading import time import random import pickle +import logging import Utils from .models import * @@ -128,15 +128,21 @@ def run_server_process(room_id, ponyconfig: dict): ping_interval=None) await ctx.server + port = 0 for wssocket in ctx.server.ws_server.sockets: socketname = wssocket.getsockname() if wssocket.family == socket.AF_INET6: logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}') - with db_session: - room = Room.get(id=ctx.room_id) - room.last_port = socketname[1] + # Prefer IPv4, as most users seem to not have working ipv6 support + if not port: + port = socketname[1] elif wssocket.family == socket.AF_INET: logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}') + port = socketname[1] + if port: + with db_session: + room = Room.get(id=ctx.room_id) + room.last_port = port with db_session: ctx.auto_shutdown = Room.get(id=room_id).timeout ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [])) @@ -146,6 +152,3 @@ def run_server_process(room_id, ponyconfig: dict): from .autolauncher import Locker with Locker(room_id): asyncio.run(main()) - - -from WebHostLib import LOGS_FOLDER diff --git a/WebHostLib/downloads.py b/WebHostLib/downloads.py index f94e8e30..9b93b82c 100644 --- a/WebHostLib/downloads.py +++ b/WebHostLib/downloads.py @@ -25,7 +25,7 @@ def download_patch(room_id, patch_id): with zipfile.ZipFile(filelike, "a") as zf: with zf.open("archipelago.json", "r") as f: manifest = json.load(f) - manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" + manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None with zipfile.ZipFile(new_file, "w") as new_zip: for file in zf.infolist(): if file.filename == "archipelago.json": @@ -55,7 +55,7 @@ def download_spoiler(seed_id): def download_slot_file(room_id, player_id: int): room = Room.get(id=room_id) slot_data: Slot = select(patch for patch in room.seed.slots if - patch.player_id == player_id).first() + patch.player_id == player_id).first() if not slot_data: return "Slot Data not found" @@ -71,7 +71,7 @@ def download_slot_file(room_id, player_id: int): with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf: for name in zf.namelist(): if name.endswith("info.json"): - fname = name.rsplit("/", 1)[0]+".zip" + fname = name.rsplit("/", 1)[0] + ".zip" elif slot_data.game == "Ocarina of Time": fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5" elif slot_data.game == "VVVVVV": @@ -82,6 +82,7 @@ def download_slot_file(room_id, player_id: int): return "Game download not supported." return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname) + @app.route("/templates") @cache.cached() def list_yaml_templates(): @@ -90,4 +91,4 @@ def list_yaml_templates(): for world_name, world in AutoWorldRegister.world_types.items(): if not world.hidden: files.append(world_name) - return render_template("templates.html", files=files) \ No newline at end of file + return render_template("templates.html", files=files)