From d190fe65c6d886e9710b7c5d01f50483276fd604 Mon Sep 17 00:00:00 2001
From: Fabian Dill
Date: Fri, 10 Jul 2020 17:42:22 +0200
Subject: [PATCH] webhost update
---
.gitignore | 8 ++-
MultiServer.py | 5 +-
WebHost.py | 57 ++++++---------
WebHost/__init__.py | 40 ++---------
WebHost/autolauncher.py | 120 +++++++++++++++++++++++++++++++
WebHost/customserver.py | 18 +++--
WebHost/models.py | 2 +-
WebHost/requirements.txt | 4 +-
WebHost/templates/host_room.html | 2 +-
WebHost/templates/landing.html | 2 +
host.yaml | 2 +-
11 files changed, 177 insertions(+), 83 deletions(-)
create mode 100644 WebHost/autolauncher.py
diff --git a/.gitignore b/.gitignore
index 4166b0ae..ecd316cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,17 +1,21 @@
.idea
.vscode
+
*_Spoiler.txt
*.bmbp
*.pyc
+*.pyd
*.sfc
*.wixobj
+*.lck
+*multidata
+*multisave
+
build
bundle/components.wxs
dist
README.html
.vs/
-*multidata
-*multisave
EnemizerCLI/
.mypy_cache/
RaceRom.py
diff --git a/MultiServer.py b/MultiServer.py
index 6354a2d2..ce518614 100644
--- a/MultiServer.py
+++ b/MultiServer.py
@@ -104,6 +104,7 @@ class Context(Node):
with open(multidatapath, 'rb') as f:
self._load(json.loads(zlib.decompress(f.read()).decode("utf-8-sig")),
use_embedded_server_options)
+
self.data_filename = multidatapath
def _load(self, jsonobj: dict, use_embedded_server_options: bool):
@@ -1177,7 +1178,7 @@ def parse_args() -> argparse.Namespace:
async def auto_shutdown(ctx, to_cancel=None):
- await asyncio.sleep(ctx.auto_shutdown * 60)
+ await asyncio.sleep(ctx.auto_shutdown)
while ctx.running:
if not ctx.client_activity_timers.values():
asyncio.create_task(ctx.server.ws_server._close())
@@ -1189,7 +1190,7 @@ async def auto_shutdown(ctx, to_cancel=None):
else:
newest_activity = max(ctx.client_activity_timers.values())
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
- seconds = ctx.auto_shutdown * 60 - delta.total_seconds()
+ seconds = ctx.auto_shutdown - delta.total_seconds()
if seconds < 0:
asyncio.create_task(ctx.server.ws_server._close())
ctx.running = False
diff --git a/WebHost.py b/WebHost.py
index 5e635ed3..88e994bf 100644
--- a/WebHost.py
+++ b/WebHost.py
@@ -2,41 +2,17 @@ import os
import multiprocessing
import logging
-from WebHost import app
+from WebHost import app as raw_app
from waitress import serve
-from WebHost.models import db, Room, db_session, select
+from WebHost.models import db
+from WebHost.autolauncher import autohost
+
+configpath = "config.yaml"
-
-def autohost(config: dict):
- return
- # not implemented yet. https://github.com/ponyorm/pony/issues/527
- import time
- from datetime import timedelta, datetime
-
- def keep_running():
- # db.bind(**config["PONY"])
- # db.generate_mapping(check_tables=False)
- while 1:
- time.sleep(3)
- with db_session:
- rooms = select(
- room for room in Room if
- room.last_activity >= datetime.utcnow() - timedelta(hours=room.timeout))
- logging.info(rooms)
-
- import threading
- threading.Thread(target=keep_running).start()
-
-
-if __name__ == "__main__":
- multiprocessing.freeze_support()
- multiprocessing.set_start_method('spawn')
- logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
-
- configpath = "config.yaml"
-
+def get_app():
+ app = raw_app
if os.path.exists(configpath):
import yaml
with open(configpath) as c:
@@ -45,8 +21,19 @@ if __name__ == "__main__":
logging.info(f"Updated config from {configpath}")
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)
- if app.config["DEBUG"]:
+ return app
+
+
+if __name__ == "__main__":
+ multiprocessing.freeze_support()
+ multiprocessing.set_start_method('spawn')
+ logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
+ app = get_app()
+ if app.config["SELFLAUNCH"]:
autohost(app.config)
- app.run(debug=True, port=app.config["PORT"])
- else:
- serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
+ 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/WebHost/__init__.py b/WebHost/__init__.py
index 251e4fb8..be56d2f7 100644
--- a/WebHost/__init__.py
+++ b/WebHost/__init__.py
@@ -2,9 +2,6 @@
So unless you're Berserker you need to include license information."""
import os
-import logging
-import typing
-import multiprocessing
import threading
from pony.flask import Pony
@@ -23,9 +20,12 @@ os.makedirs(LOGS_FOLDER, exist_ok=True)
def allowed_file(filename):
return filename.endswith(('multidata', ".zip"))
+
app = Flask(__name__)
Pony(app)
+app.config["SELFHOST"] = True
+app.config["SELFLAUNCH"] = True
app.config["DEBUG"] = False
app.config["PORT"] = 80
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
@@ -47,7 +47,6 @@ cache = Cache(app)
Compress(app)
# this local cache is risky business if app hosting is done with subprocesses as it will not sync. Waitress is fine though
-multiworlds = {}
@app.before_request
@@ -57,29 +56,6 @@ def register_session():
session["_id"] = uuid4() # uniquely identify each session without needing a login
-class MultiworldInstance():
- def __init__(self, room: Room):
- self.room_id = room.id
- self.process: typing.Optional[multiprocessing.Process] = None
- multiworlds[self.room_id] = self
-
- def start(self):
- if self.process and self.process.is_alive():
- return False
-
- logging.info(f"Spinning up {self.room_id}")
- with db_session:
- self.process = multiprocessing.Process(group=None, target=run_server_process,
- args=(self.room_id, app.config["PONY"]),
- name="MultiHost")
- self.process.start()
-
- def stop(self):
- if self.process:
- self.process.terminate()
- self.process = None
-
-
@app.route('/seed/')
def view_seed(seed: UUID):
seed = Seed.get(id=seed)
@@ -114,8 +90,6 @@ def display_log(room: UUID):
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
-processstartlock = threading.Lock()
-
@app.route('/hosted/', methods=['GET', 'POST'])
def host_room(room: UUID):
@@ -127,13 +101,9 @@ def host_room(room: UUID):
cmd = request.form["cmd"]
Command(room=room, commandtext=cmd)
commit()
- with db_session:
- multiworld = multiworlds.get(room.id, None)
- if not multiworld:
- multiworld = MultiworldInstance(room)
- with processstartlock:
- multiworld.start()
+ with db_session:
+ room.last_activity = datetime.utcnow() # will trigger a spinup, if it's not already running
return render_template("host_room.html", room=room)
diff --git a/WebHost/autolauncher.py b/WebHost/autolauncher.py
new file mode 100644
index 00000000..76bf5486
--- /dev/null
+++ b/WebHost/autolauncher.py
@@ -0,0 +1,120 @@
+from __future__ import annotations
+import logging
+import multiprocessing
+from datetime import timedelta, datetime
+import sys
+import typing
+
+from pony.orm import db_session, select
+
+
+class CommonLocker():
+ """Uses a file lock to signal that something is already running"""
+
+ def __init__(self, lockname: str):
+ self.lockname = lockname
+ self.lockfile = f"./{self.lockname}.lck"
+
+
+class AlreadyRunningException(Exception):
+ pass
+
+
+if sys.platform == 'win32':
+ import os
+
+
+ class Locker(CommonLocker):
+ def __enter__(self):
+ try:
+ if os.path.exists(self.lockfile):
+ os.unlink(self.lockfile)
+ self.fp = os.open(
+ self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
+ except OSError as e:
+ raise AlreadyRunningException() from e
+
+ def __exit__(self, _type, value, tb):
+ fp = getattr(self, "fp", None)
+ if fp:
+ os.close(self.fp)
+ os.unlink(self.lockfile)
+else: # unix
+ import fcntl
+
+
+ class Locker(CommonLocker):
+ def __enter__(self):
+ self.fp = open(self.lockfile, "rb")
+ try:
+ fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX)
+ except OSError as e:
+ raise AlreadyRunningException() from e
+
+ def __exit__(self, _type, value, tb):
+ fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
+ self.fp.close()
+
+
+def launch_room(room: Room, config: dict):
+ # requires db_session!
+ if room.last_activity >= datetime.utcnow() - timedelta(minutes=room.timeout):
+ multiworld = multiworlds.get(room.id, None)
+ if not multiworld:
+ multiworld = MultiworldInstance(room, config)
+
+ multiworld.start()
+
+
+def autohost(config: dict):
+ import time
+
+ def keep_running():
+ try:
+ with Locker("autohost"):
+ # db.bind(**config["PONY"])
+ # db.generate_mapping(check_tables=False)
+ while 1:
+ time.sleep(3)
+ with db_session:
+ rooms = select(
+ room for room in Room if
+ room.last_activity >= datetime.utcnow() - timedelta(days=3))
+ for room in rooms:
+ launch_room(room, config)
+
+ except AlreadyRunningException:
+ pass
+
+ import threading
+ threading.Thread(target=keep_running).start()
+
+
+multiworlds = {}
+
+
+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
+ self.ponyconfig = config["PONY"]
+
+ def start(self):
+ if self.process and self.process.is_alive():
+ 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()
+
+ def stop(self):
+ if self.process:
+ self.process.terminate()
+ self.process = None
+
+
+from .models import Room
+from .customserver import run_server_process
diff --git a/WebHost/customserver.py b/WebHost/customserver.py
index a80f8b0d..7dc845b4 100644
--- a/WebHost/customserver.py
+++ b/WebHost/customserver.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import functools
import logging
import os
@@ -8,7 +10,7 @@ import threading
import time
import random
-from WebHost import LOGS_FOLDER
+
from .models import *
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
@@ -16,6 +18,7 @@ from Utils import get_public_ipv4, get_public_ipv6
class CustomClientMessageProcessor(ClientMessageProcessor):
+ ctx: WebHostContext
def _cmd_video(self, platform, user):
"""Set a link for your name in the WebHost tracker pointing to a video stream"""
if platform.lower().startswith("t"): # twitch
@@ -28,7 +31,6 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
# inject
import MultiServer
-
MultiServer.client_message_processor = CustomClientMessageProcessor
del (MultiServer)
@@ -80,7 +82,10 @@ class WebHostContext(Context):
@db_session
def _save(self) -> bool:
- Room.get(id=self.room_id).multisave = self.get_save()
+ room = Room.get(id=self.room_id)
+ room.multisave = self.get_save()
+ # saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
+ room.last_activity = datetime.utcnow()
return True
def get_save(self) -> dict:
@@ -104,7 +109,6 @@ def run_server_process(room_id, ponyconfig: dict):
logging.FileHandler(os.path.join(LOGS_FOLDER, f"{room_id}.txt"), 'a', 'utf-8-sig')])
ctx = WebHostContext()
ctx.load(room_id)
- ctx.auto_shutdown = 24 * 60 * 60 # 24 hours
ctx.init_save()
try:
@@ -126,9 +130,13 @@ def run_server_process(room_id, ponyconfig: dict):
room.last_port = socketname[1]
elif wssocket.family == socket.AF_INET:
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
- ctx.auto_shutdown = 6 * 60
+ with db_session:
+ ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
logging.info("Shutting down")
asyncio.run(main())
+
+
+from WebHost import LOGS_FOLDER
diff --git a/WebHost/models.py b/WebHost/models.py
index 1dd49633..58a749b5 100644
--- a/WebHost/models.py
+++ b/WebHost/models.py
@@ -21,7 +21,7 @@ class Room(db.Entity):
seed = Required('Seed', index=True)
multisave = Optional(Json, lazy=True)
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
- timeout = Required(int, default=lambda: 6)
+ timeout = Required(int, default=lambda: 6 * 60 * 60) # seconds since last activity to shutdown
tracker = Optional(UUID, index=True)
last_port = Optional(int, default=lambda: 0)
diff --git a/WebHost/requirements.txt b/WebHost/requirements.txt
index d82c5314..0e14504f 100644
--- a/WebHost/requirements.txt
+++ b/WebHost/requirements.txt
@@ -3,4 +3,6 @@ pony>=0.7.13
waitress>=1.4.4
flask-caching>=1.9.0
Flask-Autoversion>=0.2.0
-Flask-Compress>=1.5.0
\ No newline at end of file
+Flask-Compress>=1.5.0
+redis>=3.5.3
+rq>=1.4.3
\ No newline at end of file
diff --git a/WebHost/templates/host_room.html b/WebHost/templates/host_room.html
index 7946f44c..3b0b5212 100644
--- a/WebHost/templates/host_room.html
+++ b/WebHost/templates/host_room.html
@@ -9,7 +9,7 @@
{% if room.tracker %}
This room has a Multiworld Tracker enabled.
{% endif %}
- This room will be closed after {{ room.timeout }} hours of inactivity. Should you wish to continue later,
+ This room will be closed after {{ room.timeout//60//60 }} hours of inactivity. Should you wish to continue later,
you can simply refresh this page and the server will be started again.
{% if room.owner == session["_id"] %}
+
This webpage is still under heavy construction. Database may be wiped as I see fit
+ and some stuff may be broken.
This is a randomizer for The Legend of Zelda: A Link to the Past.
It is a multiworld, meaning items get shuffled across multiple players' worlds
which get exchanged on pickup through the internet.
diff --git a/host.yaml b/host.yaml
index c5bf44af..748cdeaa 100644
--- a/host.yaml
+++ b/host.yaml
@@ -34,7 +34,7 @@ server_options:
# "goal" -> client can ask for remaining items after goal completion
# warning: only Berserker's Multiworld clients of version 2.1+ send game beaten information
remaining_mode: "goal"
- # automatically shut down the server after this many minutes without new location checks, 0 to keep running
+ # automatically shut down the server after this many seconds without new location checks, 0 to keep running
auto_shutdown: 0
#options for MultiMystery.py
multi_mystery_options: