Core, WebHost: lazy-load worlds in unpickler, WebHost and WebHostLib (#2156)
* Core: lazy-load worlds in unpickler this should hopefully fix customserver's memory consumption * WebHost: move imports around to save memory in MP * MultiServer: prefer loading _speedups without pyximport This saves ~15MB per MP and speeds up module import if it was built in-place. * Tests: fix tests for changed WebHost imports * CustomServer: run GC after setup * CustomServer: cleanup exception handling
This commit is contained in:
parent
4a27fae1ab
commit
d471dcc067
24
NetUtils.py
24
NetUtils.py
|
@ -407,14 +407,22 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
||||||
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
||||||
LocationStore = _LocationStore
|
LocationStore = _LocationStore
|
||||||
else:
|
else:
|
||||||
try:
|
|
||||||
import pyximport
|
|
||||||
pyximport.install()
|
|
||||||
except ImportError:
|
|
||||||
pyximport = None
|
|
||||||
try:
|
try:
|
||||||
from _speedups import LocationStore
|
from _speedups import LocationStore
|
||||||
|
import _speedups
|
||||||
|
import os.path
|
||||||
|
if os.path.getctime(_speedups.__file__) < os.path.getctime("_speedups.pyx"):
|
||||||
|
warnings.warn(f"{_speedups.__file__} outdated! "
|
||||||
|
f"Please rebuild with `cythonize -b -i _speedups.pyx` or delete it!")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
|
try:
|
||||||
"Install a matching C++ compiler for your platform to compile _speedups.")
|
import pyximport
|
||||||
LocationStore = _LocationStore
|
pyximport.install()
|
||||||
|
except ImportError:
|
||||||
|
pyximport = None
|
||||||
|
try:
|
||||||
|
from _speedups import LocationStore
|
||||||
|
except ImportError:
|
||||||
|
warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
|
||||||
|
"Install a matching C++ compiler for your platform to compile _speedups.")
|
||||||
|
LocationStore = _LocationStore
|
||||||
|
|
6
Utils.py
6
Utils.py
|
@ -359,11 +359,13 @@ safe_builtins = frozenset((
|
||||||
|
|
||||||
|
|
||||||
class RestrictedUnpickler(pickle.Unpickler):
|
class RestrictedUnpickler(pickle.Unpickler):
|
||||||
|
generic_properties_module: Optional[object]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
|
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
|
||||||
self.options_module = importlib.import_module("Options")
|
self.options_module = importlib.import_module("Options")
|
||||||
self.net_utils_module = importlib.import_module("NetUtils")
|
self.net_utils_module = importlib.import_module("NetUtils")
|
||||||
self.generic_properties_module = importlib.import_module("worlds.generic")
|
self.generic_properties_module = None
|
||||||
|
|
||||||
def find_class(self, module, name):
|
def find_class(self, module, name):
|
||||||
if module == "builtins" and name in safe_builtins:
|
if module == "builtins" and name in safe_builtins:
|
||||||
|
@ -373,6 +375,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||||
return getattr(self.net_utils_module, name)
|
return getattr(self.net_utils_module, name)
|
||||||
# Options and Plando are unpickled by WebHost -> Generate
|
# Options and Plando are unpickled by WebHost -> Generate
|
||||||
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
||||||
|
if not self.generic_properties_module:
|
||||||
|
self.generic_properties_module = importlib.import_module("worlds.generic")
|
||||||
return getattr(self.generic_properties_module, name)
|
return getattr(self.generic_properties_module, name)
|
||||||
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
|
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
|
||||||
if module.lower().endswith("options"):
|
if module.lower().endswith("options"):
|
||||||
|
|
18
WebHost.py
18
WebHost.py
|
@ -13,15 +13,6 @@ import Utils
|
||||||
import settings
|
import settings
|
||||||
|
|
||||||
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
||||||
|
|
||||||
from WebHostLib import register, cache, app as raw_app
|
|
||||||
from waitress import serve
|
|
||||||
|
|
||||||
from WebHostLib.models import db
|
|
||||||
from WebHostLib.autolauncher import autohost, autogen
|
|
||||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
|
||||||
from WebHostLib.options import create as create_options_files
|
|
||||||
|
|
||||||
settings.no_gui = True
|
settings.no_gui = True
|
||||||
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
|
||||||
|
@ -29,6 +20,9 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||||
|
|
||||||
|
|
||||||
def get_app():
|
def get_app():
|
||||||
|
from WebHostLib import register, cache, app as raw_app
|
||||||
|
from WebHostLib.models import db
|
||||||
|
|
||||||
register()
|
register()
|
||||||
app = raw_app
|
app = raw_app
|
||||||
if os.path.exists(configpath) and not app.config["TESTING"]:
|
if os.path.exists(configpath) and not app.config["TESTING"]:
|
||||||
|
@ -121,6 +115,11 @@ if __name__ == "__main__":
|
||||||
multiprocessing.freeze_support()
|
multiprocessing.freeze_support()
|
||||||
multiprocessing.set_start_method('spawn')
|
multiprocessing.set_start_method('spawn')
|
||||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||||
|
|
||||||
|
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||||
|
from WebHostLib.autolauncher import autohost, autogen
|
||||||
|
from WebHostLib.options import create as create_options_files
|
||||||
|
|
||||||
try:
|
try:
|
||||||
update_sprites_lttp()
|
update_sprites_lttp()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -137,4 +136,5 @@ if __name__ == "__main__":
|
||||||
if app.config["DEBUG"]:
|
if app.config["DEBUG"]:
|
||||||
app.run(debug=True, port=app.config["PORT"])
|
app.run(debug=True, port=app.config["PORT"])
|
||||||
else:
|
else:
|
||||||
|
from waitress import serve
|
||||||
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
|
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
|
||||||
|
|
|
@ -3,8 +3,6 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
|
@ -13,55 +11,7 @@ from datetime import timedelta, datetime
|
||||||
from pony.orm import db_session, select, commit
|
from pony.orm import db_session, select, commit
|
||||||
|
|
||||||
from Utils import restricted_loads
|
from Utils import restricted_loads
|
||||||
|
from .locker import Locker, AlreadyRunningException
|
||||||
|
|
||||||
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
|
|
||||||
os.makedirs(self.lock_folder, exist_ok=True)
|
|
||||||
self.lockname = lockname
|
|
||||||
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
|
|
||||||
|
|
||||||
|
|
||||||
class AlreadyRunningException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if sys.platform == 'win32':
|
|
||||||
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):
|
|
||||||
try:
|
|
||||||
self.fp = open(self.lockfile, "wb")
|
|
||||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
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):
|
def launch_room(room: Room, config: dict):
|
||||||
|
|
|
@ -19,6 +19,7 @@ import Utils
|
||||||
|
|
||||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
||||||
from Utils import restricted_loads, cache_argsless
|
from Utils import restricted_loads, cache_argsless
|
||||||
|
from .locker import Locker
|
||||||
from .models import Command, GameDataPackage, Room, db
|
from .models import Command, GameDataPackage, Room, db
|
||||||
|
|
||||||
|
|
||||||
|
@ -163,16 +164,19 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
|
||||||
db.generate_mapping(check_tables=False)
|
db.generate_mapping(check_tables=False)
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
import gc
|
||||||
|
|
||||||
Utils.init_logging(str(room_id), write_mode="a")
|
Utils.init_logging(str(room_id), write_mode="a")
|
||||||
ctx = WebHostContext(static_server_data)
|
ctx = WebHostContext(static_server_data)
|
||||||
ctx.load(room_id)
|
ctx.load(room_id)
|
||||||
ctx.init_save()
|
ctx.init_save()
|
||||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
||||||
|
gc.collect() # free intermediate objects used during setup
|
||||||
try:
|
try:
|
||||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
|
except OSError: # likely port in use
|
||||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
|
@ -198,16 +202,15 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
|
||||||
await ctx.shutdown_task
|
await ctx.shutdown_task
|
||||||
logging.info("Shutting down")
|
logging.info("Shutting down")
|
||||||
|
|
||||||
from .autolauncher import Locker
|
|
||||||
with Locker(room_id):
|
with Locker(room_id):
|
||||||
try:
|
try:
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
except KeyboardInterrupt:
|
except (KeyboardInterrupt, SystemExit):
|
||||||
with db_session:
|
with db_session:
|
||||||
room = Room.get(id=room_id)
|
room = Room.get(id=room_id)
|
||||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||||
except:
|
except Exception:
|
||||||
with db_session:
|
with db_session:
|
||||||
room = Room.get(id=room_id)
|
room = Room.get(id=room_id)
|
||||||
room.last_port = -1
|
room.last_port = -1
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
os.makedirs(self.lock_folder, exist_ok=True)
|
||||||
|
self.lockname = lockname
|
||||||
|
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadyRunningException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
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):
|
||||||
|
try:
|
||||||
|
self.fp = open(self.lockfile, "wb")
|
||||||
|
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
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()
|
|
@ -5,7 +5,8 @@ import json
|
||||||
class TestDocs(unittest.TestCase):
|
class TestDocs(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls) -> None:
|
def setUpClass(cls) -> None:
|
||||||
from WebHost import get_app, raw_app
|
from WebHostLib import app as raw_app
|
||||||
|
from WebHost import get_app
|
||||||
raw_app.config["PONY"] = {
|
raw_app.config["PONY"] = {
|
||||||
"provider": "sqlite",
|
"provider": "sqlite",
|
||||||
"filename": ":memory:",
|
"filename": ":memory:",
|
||||||
|
|
|
@ -14,7 +14,8 @@ class TestFileGeneration(unittest.TestCase):
|
||||||
cls.incorrect_path = os.path.join(os.path.split(os.path.dirname(__file__))[0], "WebHostLib")
|
cls.incorrect_path = os.path.join(os.path.split(os.path.dirname(__file__))[0], "WebHostLib")
|
||||||
|
|
||||||
def testOptions(self):
|
def testOptions(self):
|
||||||
WebHost.create_options_files()
|
from WebHostLib.options import create as create_options_files
|
||||||
|
create_options_files()
|
||||||
target = os.path.join(self.correct_path, "static", "generated", "configs")
|
target = os.path.join(self.correct_path, "static", "generated", "configs")
|
||||||
self.assertTrue(os.path.exists(target))
|
self.assertTrue(os.path.exists(target))
|
||||||
self.assertFalse(os.path.exists(os.path.join(self.incorrect_path, "static", "generated", "configs")))
|
self.assertFalse(os.path.exists(os.path.join(self.incorrect_path, "static", "generated", "configs")))
|
||||||
|
|
Loading…
Reference in New Issue