WebHost: some updates (#603)

* WebHost: Make custom server prefer ipv4 for display

* WebHost: Make server retry saving in case of connection issues

* WebHost: fix autolaunch guardians getting stuck waiting for the oldest two rooms.
Probably not related to the issues of the system itself getting stuck, but should be fixed anyway.

* WebHost: logfile is meant to be guarded by access cookie

* WebHost: set patch target to null if port is not valid, disabling auto-connect
This commit is contained in:
Fabian Dill 2022-06-08 00:35:35 +02:00 committed by GitHub
parent 517a2db9d8
commit e47527087e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 74 additions and 32 deletions

View File

@ -23,6 +23,11 @@ ModuleUpdate.update()
import websockets import websockets
import colorama 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 import NetUtils
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
@ -404,12 +409,16 @@ class Context:
def save_regularly(): def save_regularly():
import time import time
while not self.exit_event.is_set(): while not self.exit_event.is_set():
try:
time.sleep(self.auto_save_interval) time.sleep(self.auto_save_interval)
if self.save_dirty: if self.save_dirty:
logging.debug("Saving via thread.") logging.debug("Saving via thread.")
self.save_dirty = False
self._save() 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.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True) self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
self.auto_saver_thread.start() self.auto_saver_thread.start()

View File

@ -22,7 +22,7 @@ from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.lttpsprites import update_sprites_lttp from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files 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") configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home 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']: for guide in game_data['tutorials']:
if guide and tutorial.tutorial_name == guide['name']: if guide and tutorial.tutorial_name == guide['name']:
guide['files'].append(current_tutorial['files'][0]) guide['files'].append(current_tutorial['files'][0])
added = True
break break
else: else:
game_data['tutorials'].append(current_tutorial) game_data['tutorials'].append(current_tutorial)
@ -109,7 +108,6 @@ if __name__ == "__main__":
autogen(app.config) autogen(app.config)
if app.config["SELFHOST"]: # using WSGI, you just want to run get_app() if app.config["SELFHOST"]: # using WSGI, you just want to run get_app()
if app.config["DEBUG"]: if app.config["DEBUG"]:
autohost(app.config)
app.run(debug=True, port=app.config["PORT"]) app.run(debug=True, port=app.config["PORT"])
else: else:
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"]) serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])

View File

@ -170,7 +170,9 @@ def _read_log(path: str):
@app.route('/log/<suuid:room>') @app.route('/log/<suuid:room>')
def display_log(room: UUID): def display_log(room: UUID):
if room.owner == session["_id"]:
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8") return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
return "Access Denied", 403
@app.route('/room/<suuid:room>', methods=['GET', 'POST']) @app.route('/room/<suuid:room>', methods=['GET', 'POST'])

View File

@ -2,8 +2,9 @@ from __future__ import annotations
import logging import logging
import json import json
import multiprocessing import multiprocessing
import threading
from datetime import timedelta, datetime from datetime import timedelta, datetime
import concurrent.futures
import sys import sys
import typing import typing
import time import time
@ -17,6 +18,7 @@ from Utils import restricted_loads
class CommonLocker(): class CommonLocker():
"""Uses a file lock to signal that something is already running""" """Uses a file lock to signal that something is already running"""
lock_folder = "file_locks" lock_folder = "file_locks"
def __init__(self, lockname: str, folder=None): def __init__(self, lockname: str, folder=None):
if folder: if folder:
self.lock_folder = folder self.lock_folder = folder
@ -110,6 +112,7 @@ def autohost(config: dict):
def keep_running(): def keep_running():
try: try:
with Locker("autohost"): with Locker("autohost"):
run_guardian()
while 1: while 1:
time.sleep(0.1) time.sleep(0.1)
with db_session: with db_session:
@ -162,15 +165,14 @@ def autogen(config: dict):
threading.Thread(target=keep_running, name="AP_Autogen").start() threading.Thread(target=keep_running, name="AP_Autogen").start()
multiworlds = {} multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
guardians = concurrent.futures.ThreadPoolExecutor(2, thread_name_prefix="Guardian")
class MultiworldInstance(): class MultiworldInstance():
def __init__(self, room: Room, config: dict): def __init__(self, room: Room, config: dict):
self.room_id = room.id self.room_id = room.id
self.process: typing.Optional[multiprocessing.Process] = None self.process: typing.Optional[multiprocessing.Process] = None
with guardian_lock:
multiworlds[self.room_id] = self multiworlds[self.room_id] = self
self.ponyconfig = config["PONY"] self.ponyconfig = config["PONY"]
@ -179,21 +181,48 @@ class MultiworldInstance():
return False return False
logging.info(f"Spinning up {self.room_id}") logging.info(f"Spinning up {self.room_id}")
self.process = multiprocessing.Process(group=None, target=run_server_process, process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.room_id, self.ponyconfig), args=(self.room_id, self.ponyconfig),
name="MultiHost") name="MultiHost")
self.process.start() process.start()
self.guardian = guardians.submit(self._collect) # bind after start to prevent thread sync issues with guardian.
self.process = process
def stop(self): def stop(self):
if self.process: if self.process:
self.process.terminate() self.process.terminate()
self.process = None 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.join() # wait for process to finish
self.process = None 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 from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed

View File

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import functools import functools
import logging
import websockets import websockets
import asyncio import asyncio
import socket import socket
@ -9,6 +8,7 @@ import threading
import time import time
import random import random
import pickle import pickle
import logging
import Utils import Utils
from .models import * from .models import *
@ -128,15 +128,21 @@ def run_server_process(room_id, ponyconfig: dict):
ping_interval=None) ping_interval=None)
await ctx.server await ctx.server
port = 0
for wssocket in ctx.server.ws_server.sockets: for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname() socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6: if wssocket.family == socket.AF_INET6:
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}') logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
with db_session: # Prefer IPv4, as most users seem to not have working ipv6 support
room = Room.get(id=ctx.room_id) if not port:
room.last_port = socketname[1] port = socketname[1]
elif wssocket.family == socket.AF_INET: elif wssocket.family == socket.AF_INET:
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}') 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: with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [])) 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 from .autolauncher import Locker
with Locker(room_id): with Locker(room_id):
asyncio.run(main()) asyncio.run(main())
from WebHostLib import LOGS_FOLDER

View File

@ -25,7 +25,7 @@ def download_patch(room_id, patch_id):
with zipfile.ZipFile(filelike, "a") as zf: with zipfile.ZipFile(filelike, "a") as zf:
with zf.open("archipelago.json", "r") as f: with zf.open("archipelago.json", "r") as f:
manifest = json.load(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: with zipfile.ZipFile(new_file, "w") as new_zip:
for file in zf.infolist(): for file in zf.infolist():
if file.filename == "archipelago.json": if file.filename == "archipelago.json":
@ -71,7 +71,7 @@ def download_slot_file(room_id, player_id: int):
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf: with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist(): for name in zf.namelist():
if name.endswith("info.json"): if name.endswith("info.json"):
fname = name.rsplit("/", 1)[0]+".zip" fname = name.rsplit("/", 1)[0] + ".zip"
elif slot_data.game == "Ocarina of Time": 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" 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": elif slot_data.game == "VVVVVV":
@ -82,6 +82,7 @@ def download_slot_file(room_id, player_id: int):
return "Game download not supported." return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname) return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
@app.route("/templates") @app.route("/templates")
@cache.cached() @cache.cached()
def list_yaml_templates(): def list_yaml_templates():