Source code for evennia.server.service

"""
This module contains the main EvenniaService class, which is the very core of the
Evennia server. It is instantiated by the evennia/server/server.py module.
"""
import importlib
import time
import traceback

import django
import evennia
from django.conf import settings
from django.db import connection
from django.db.utils import OperationalError
from django.utils.translation import gettext as _
from evennia.utils import logger
from evennia.utils.utils import get_evennia_version, make_iter, mod_import
from twisted.application import internet
from twisted.application.service import MultiService
from twisted.internet import defer, reactor
from twisted.internet.defer import Deferred
from twisted.internet.task import LoopingCall

_SA = object.__setattr__


[docs]class EvenniaServerService(MultiService): def _wrap_sigint_handler(self, *args): if hasattr(self, "web_root"): d = self.web_root.empty_threadpool() d.addCallback(lambda _: self.shutdown("reload", _reactor_stopping=True)) else: d = Deferred(lambda _: self.shutdown("reload", _reactor_stopping=True)) d.addCallback(lambda _: reactor.stop()) reactor.callLater(1, d.callback, None)
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.maintenance_count = 0 self.amp_protocol = None # set by amp factory self.amp_service = None self.info_dict = { "servername": settings.SERVERNAME, "version": get_evennia_version(), "amp": "", "errors": "", "info": "", "webserver": "", "irc_rss": "", } self._flush_cache = None self._last_server_time_snapshot = 0 self.maintenance_task = None # Database-specific startup optimizations. self.sqlite3_prep() self.start_time = 0 # wrap the SIGINT handler to make sure we empty the threadpool # even when we reload and we have long-running requests in queue. # this is necessary over using Twisted's signal handler. # (see https://github.com/evennia/evennia/issues/1128) reactor.sigInt = self._wrap_sigint_handler self.start_stop_modules = [ mod_import(mod) for mod in make_iter(settings.AT_SERVER_STARTSTOP_MODULE) if isinstance(mod, str) ]
[docs] def server_maintenance(self): """ This maintenance function handles repeated checks and updates that the server needs to do. It is called every minute. """ if not self._flush_cache: from evennia.utils.idmapper.models import conditional_flush as _FLUSH_CACHE self._flush_cache = _FLUSH_CACHE self.maintenance_count += 1 now = time.time() if self.maintenance_count == 1: # first call after a reload evennia.gametime.SERVER_START_TIME = now evennia.gametime.SERVER_RUNTIME = evennia.ServerConfig.objects.conf( "runtime", default=0.0 ) _LAST_SERVER_TIME_SNAPSHOT = now else: # adjust the runtime not with 60s but with the actual elapsed time # in case this may varies slightly from 60s. evennia.gametime.SERVER_RUNTIME += now - self._last_server_time_snapshot self._last_server_time_snapshot = now # update game time and save it across reloads evennia.gametime.SERVER_RUNTIME_LAST_UPDATED = now evennia.ServerConfig.objects.conf("runtime", evennia.gametime.SERVER_RUNTIME) if self.maintenance_count % 5 == 0: # check cache size every 5 minutes self._flush_cache(settings.IDMAPPER_CACHE_MAXSIZE) if self.maintenance_count % (60 * 7) == 0: # drop database connection every 7 hrs to avoid default timeouts on MySQL # (see https://github.com/evennia/evennia/issues/1376) connection.close() self.process_idle_timeouts() # run unpuppet hooks for objects that are marked as being puppeted, # but which lacks an account (indicates a broken unpuppet operation # such as a server crash) if self.maintenance_count > 1: unpuppet_count = 0 for obj in evennia.ObjectDB.objects.get_by_tag(key="puppeted", category="account"): if not obj.has_account: obj.at_pre_unpuppet() obj.at_post_unpuppet(None, reason=_(" (connection lost)")) obj.tags.remove("puppeted", category="account") unpuppet_count += 1 if unpuppet_count: logger.log_msg(f"Ran unpuppet-hooks for {unpuppet_count} link-dead puppets.")
[docs] def process_idle_timeouts(self): # handle idle timeouts if settings.IDLE_TIMEOUT > 0: now = time.time() reason = _("idle timeout exceeded") to_disconnect = [] for session in ( sess for sess in evennia.SESSION_HANDLER.values() if (now - sess.cmd_last) > settings.IDLE_TIMEOUT ): if not session.account or not session.account.access( session.account, "noidletimeout", default=False ): to_disconnect.append(session) for session in to_disconnect: evennia.SESSION_HANDLER.disconnect(session, reason=reason)
# Server startup methods
[docs] def privilegedStartService(self): self.start_time = time.time() # Tell the system the server is starting up; some things are not available yet try: evennia.ServerConfig.objects.conf("server_starting_mode", True) except OperationalError: print("Server server_starting_mode couldn't be set - database not set up.") if settings.AMP_ENABLED: self.register_amp() if settings.WEBSERVER_ENABLED: self.register_webserver() ENABLED = [] if settings.IRC_ENABLED: # IRC channel connections ENABLED.append("irc") if settings.RSS_ENABLED: # RSS feed channel connections ENABLED.append("rss") if settings.GRAPEVINE_ENABLED: # Grapevine channel connections ENABLED.append("grapevine") if settings.GAME_INDEX_ENABLED: from evennia.server.game_index_client.service import EvenniaGameIndexService egi_service = EvenniaGameIndexService() egi_service.setServiceParent(self) if ENABLED: self.info_dict["irc_rss"] = ", ".join(ENABLED) + " enabled." self.register_plugins() super().privilegedStartService() # clear server startup mode try: evennia.ServerConfig.objects.conf("server_starting_mode", delete=True) except OperationalError: print("Server server_starting_mode couldn't unset - db not set up.")
[docs] def register_plugins(self): SERVER_SERVICES_PLUGIN_MODULES = make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES) for plugin_module in SERVER_SERVICES_PLUGIN_MODULES: # external plugin protocols - load here plugin_module = mod_import(plugin_module) if plugin_module: plugin_module.start_plugin_services(self) else: print(f"Could not load plugin module {plugin_module}")
[docs] def register_amp(self): # The AMP protocol handles the communication between # the portal and the mud server. Only reason to ever deactivate # it would be during testing and debugging. ifacestr = "" if settings.AMP_INTERFACE != "127.0.0.1": ifacestr = "-%s" % settings.AMP_INTERFACE self.info_dict["amp"] = "amp %s: %s" % (ifacestr, settings.AMP_PORT) from evennia.server import amp_client factory = amp_client.AMPClientFactory(self) self.amp_service = internet.TCPClient(settings.AMP_HOST, settings.AMP_PORT, factory) self.amp_service.setName("ServerAMPClient") self.amp_service.setServiceParent(self)
[docs] def register_webserver(self): # Start a django-compatible webserver. from evennia.server.webserver import ( DjangoWebRoot, LockableThreadPool, PrivateStaticRoot, Website, WSGIWebServer, ) # start a thread pool and define the root url (/) as a wsgi resource # recognized by Django threads = LockableThreadPool( minthreads=max(1, settings.WEBSERVER_THREADPOOL_LIMITS[0]), maxthreads=max(1, settings.WEBSERVER_THREADPOOL_LIMITS[1]), ) web_root = DjangoWebRoot(threads) # point our media resources to url /media web_root.putChild(b"media", PrivateStaticRoot(settings.MEDIA_ROOT)) # point our static resources to url /static web_root.putChild(b"static", PrivateStaticRoot(settings.STATIC_ROOT)) self.web_root = web_root try: WEB_PLUGINS_MODULE = mod_import(settings.WEB_PLUGINS_MODULE) except ImportError: WEB_PLUGINS_MODULE = None self.info_dict["errors"] = ( "WARNING: settings.WEB_PLUGINS_MODULE not found - " "copy 'evennia/game_template/server/conf/web_plugins.py to mygame/server/conf." ) if WEB_PLUGINS_MODULE: # custom overloads web_root = WEB_PLUGINS_MODULE.at_webserver_root_creation(web_root) web_site = Website(web_root, logPath=settings.HTTP_LOG_FILE) web_site.is_portal = False self.info_dict["webserver"] = "" for proxyport, serverport in settings.WEBSERVER_PORTS: # create the webserver (we only need the port for this) webserver = WSGIWebServer(threads, serverport, web_site, interface="127.0.0.1") webserver.setName("EvenniaWebServer%s" % serverport) webserver.setServiceParent(self) self.info_dict["webserver"] += "webserver: %s" % serverport
[docs] def sqlite3_prep(self): """ Optimize some SQLite stuff at startup since we can't save it to the database. """ if ( ".".join(str(i) for i in django.VERSION) < "1.2" and settings.DATABASES.get("default", {}).get("ENGINE") == "sqlite3" ) or ( hasattr(settings, "DATABASES") and settings.DATABASES.get("default", {}).get("ENGINE", None) == "django.db.backends.sqlite3" ): cursor = connection.cursor() cursor.execute("PRAGMA cache_size=10000") cursor.execute("PRAGMA synchronous=OFF") cursor.execute("PRAGMA count_changes=OFF") cursor.execute("PRAGMA temp_store=2")
[docs] def update_defaults(self): """ We make sure to store the most important object defaults here, so we can catch if they change and update them on-objects automatically. This allows for changing default cmdset locations and default typeclasses in the settings file and have them auto-update all already existing objects. """ # setting names settings_names = ( "CMDSET_CHARACTER", "CMDSET_ACCOUNT", "BASE_ACCOUNT_TYPECLASS", "BASE_OBJECT_TYPECLASS", "BASE_CHARACTER_TYPECLASS", "BASE_ROOM_TYPECLASS", "BASE_EXIT_TYPECLASS", "BASE_SCRIPT_TYPECLASS", "BASE_CHANNEL_TYPECLASS", ) # get previous and current settings so they can be compared settings_compare = list( zip( [evennia.ServerConfig.objects.conf(name) for name in settings_names], [settings.__getattr__(name) for name in settings_names], ) ) mismatches = [ i for i, tup in enumerate(settings_compare) if tup[0] and tup[1] and tup[0] != tup[1] ] if len( mismatches ): # can't use any() since mismatches may be [0] which reads as False for any() # we have a changed default. Import relevant objects and # run the update # from evennia.accounts.models import AccountDB for i, prev, curr in ( (i, tup[0], tup[1]) for i, tup in enumerate(settings_compare) if i in mismatches ): # update the database self.info_dict[ "info" ] = " %s:\n '%s' changed to '%s'. Updating unchanged entries in database ..." % ( settings_names[i], prev, curr, ) if i == 0: evennia.ObjectDB.objects.filter(db_cmdset_storage__exact=prev).update( db_cmdset_storage=curr ) if i == 1: evennia.AccountDB.objects.filter(db_cmdset_storage__exact=prev).update( db_cmdset_storage=curr ) if i == 2: evennia.AccountDB.objects.filter(db_typeclass_path__exact=prev).update( db_typeclass_path=curr ) if i in (3, 4, 5, 6): evennia.ObjectDB.objects.filter(db_typeclass_path__exact=prev).update( db_typeclass_path=curr ) if i == 7: evennia.ScriptDB.objects.filter(db_typeclass_path__exact=prev).update( db_typeclass_path=curr ) if i == 8: evennia.ChannelDB.objects.filter(db_typeclass_path__exact=prev).update( db_typeclass_path=curr ) # store the new default and clean caches evennia.ServerConfig.objects.conf(settings_names[i], curr) evennia.ObjectDB.flush_instance_cache() evennia.AccountDB.flush_instance_cache() evennia.ScriptDB.flush_instance_cache() evennia.ChannelDB.flush_instance_cache() # if this is the first start we might not have a "previous" # setup saved. Store it now. [ evennia.ServerConfig.objects.conf(settings_names[i], tup[1]) for i, tup in enumerate(settings_compare) if not tup[0] ]
[docs] def run_initial_setup(self): """ This is triggered by the amp protocol when the connection to the portal has been established. This attempts to run the initial_setup script of the server. It returns if this is not the first time the server starts. Once finished the last_initial_setup_step is set to 'done' """ initial_setup = importlib.import_module(settings.INITIAL_SETUP_MODULE) last_initial_setup_step = evennia.ServerConfig.objects.conf("last_initial_setup_step") try: if not last_initial_setup_step: # None is only returned if the config does not exist, # i.e. this is an empty DB that needs populating. self.info_dict["info"] = " Server started for the first time. Setting defaults." initial_setup.handle_setup() elif last_initial_setup_step not in ("done", -1): # last step crashed, so we weill resume from this step. # modules and setup will resume from this step, retrying # the last failed module. When all are finished, the step # is set to 'done' to show it does not need to be run again. self.info_dict["info"] = " Resuming initial setup from step '{last}'.".format( last=last_initial_setup_step ) initial_setup.handle_setup(last_initial_setup_step) except Exception: # stop server if this happens. print(traceback.format_exc()) if not settings.TEST_ENVIRONMENT or not evennia.SESSION_HANDLER: print("Error in initial setup. Stopping Server + Portal.") evennia.SESSION_HANDLER.portal_shutdown()
[docs] def create_default_channels(self): """ check so default channels exist on every restart, create if not. """ from evennia import AccountDB, ChannelDB from evennia.utils.create import create_channel superuser = AccountDB.objects.get(id=1) # mudinfo mudinfo_chan = settings.CHANNEL_MUDINFO if mudinfo_chan and not ChannelDB.objects.filter(db_key__iexact=mudinfo_chan["key"]): channel = create_channel(**mudinfo_chan) channel.connect(superuser) # connectinfo connectinfo_chan = settings.CHANNEL_CONNECTINFO if connectinfo_chan and not ChannelDB.objects.filter( db_key__iexact=connectinfo_chan["key"] ): channel = create_channel(**connectinfo_chan) # default channels for chan_info in settings.DEFAULT_CHANNELS: if not ChannelDB.objects.filter(db_key__iexact=chan_info["key"]): channel = create_channel(**chan_info) channel.connect(superuser)
[docs] def run_init_hooks(self, mode): """ Called by the amp client once receiving sync back from Portal Args: mode (str): One of shutdown, reload or reset """ from evennia.typeclasses.models import TypedObject # start server time and maintenance task self.maintenance_task = LoopingCall(self.server_maintenance) self.maintenance_task.start(60, now=True) # call every minute # update eventual changed defaults self.update_defaults() # run at_init() on all cached entities on reconnect [ [entity.at_init() for entity in typeclass_db.get_all_cached_instances()] for typeclass_db in TypedObject.__subclasses__() ] self.at_server_init() # call correct server hook based on start file value if mode == "reload": logger.log_msg("Server successfully reloaded.") self.at_server_reload_start() elif mode == "reset": # only run hook, don't purge sessions self.at_server_cold_start() logger.log_msg("Evennia Server successfully restarted in 'reset' mode.") elif mode == "shutdown": from evennia.objects.models import ObjectDB self.at_server_cold_start() # clear eventual lingering session storages ObjectDB.objects.clear_all_sessids() logger.log_msg("Evennia Server successfully started.") # always call this regardless of start type self.at_server_start()
[docs] @defer.inlineCallbacks def shutdown(self, mode="reload", _reactor_stopping=False): """ Shuts down the server from inside it. mode - sets the server restart mode. - 'reload' - server restarts, no "persistent" scripts are stopped, at_reload hooks called. - 'reset' - server restarts, non-persistent scripts stopped, at_shutdown hooks called but sessions will not be disconnected. - 'shutdown' - like reset, but server will not auto-restart. _reactor_stopping - this is set if server is stopped by a kill command OR this method was already called once - in both cases the reactor is dead/stopping already. """ if _reactor_stopping and hasattr(self, "shutdown_complete"): # this means we have already passed through this method # once; we don't need to run the shutdown procedure again. defer.returnValue(None) if mode == "reload": # call restart hooks evennia.ServerConfig.objects.conf("server_restart_mode", "reload") yield [o.at_server_reload() for o in evennia.ObjectDB.get_all_cached_instances()] yield [p.at_server_reload() for p in evennia.AccountDB.get_all_cached_instances()] yield [ (s._pause_task(auto_pause=True) if s.is_active else None, s.at_server_reload()) for s in evennia.ScriptDB.get_all_cached_instances() if s.id ] yield evennia.SESSION_HANDLER.all_sessions_portal_sync() self.at_server_reload_stop() # only save monitor state on reload, not on shutdown/reset from evennia.scripts.monitorhandler import MONITOR_HANDLER MONITOR_HANDLER.save() else: if mode == "reset": # like shutdown but don't unset the is_connected flag and don't disconnect sessions yield [o.at_server_shutdown() for o in evennia.ObjectDB.get_all_cached_instances()] yield [p.at_server_shutdown() for p in evennia.AccountDB.get_all_cached_instances()] if self.amp_protocol: yield evennia.SESSION_HANDLER.all_sessions_portal_sync() else: # shutdown yield [ _SA(p, "is_connected", False) for p in evennia.AccountDB.get_all_cached_instances() ] yield [o.at_server_shutdown() for o in evennia.ObjectDB.get_all_cached_instances()] yield [ (p.unpuppet_all(), p.at_server_shutdown()) for p in evennia.AccountDB.get_all_cached_instances() ] yield evennia.ObjectDB.objects.clear_all_sessids() yield [ (s._pause_task(auto_pause=True), s.at_server_shutdown()) for s in evennia.ScriptDB.get_all_cached_instances() if s.id and s.is_active ] evennia.ServerConfig.objects.conf("server_restart_mode", "reset") self.at_server_cold_stop() # tickerhandler state should always be saved. from evennia.scripts.tickerhandler import TICKER_HANDLER TICKER_HANDLER.save() # always called, also for a reload self.at_server_stop() if hasattr(self, "web_root"): # not set very first start yield self.web_root.empty_threadpool() if not _reactor_stopping: # kill the server self.shutdown_complete = True reactor.callLater(1, reactor.stop) # we make sure the proper gametime is saved as late as possible evennia.ServerConfig.objects.conf("runtime", evennia.gametime.runtime())
[docs] def get_info_dict(self): """ Return the server info, for display. """ return self.info_dict
# server start/stop hooks def _call_start_stop(self, hookname): """ Helper method for calling hooks on all modules. Args: hookname (str): Name of hook to call. """ for mod in self.start_stop_modules: if hook := getattr(mod, hookname, None): hook()
[docs] def at_server_init(self): """ This is called first when the server is starting, before any other hooks, regardless of how it's starting. """ self._call_start_stop("at_server_init")
[docs] def at_server_start(self): """ This is called every time the server starts up, regardless of how it was shut down. """ self._call_start_stop("at_server_start")
[docs] def at_server_stop(self): """ This is called just before a server is shut down, regardless of it is fore a reload, reset or shutdown. """ self._call_start_stop("at_server_stop")
[docs] def at_server_reload_start(self): """ This is called only when server starts back up after a reload. """ self._call_start_stop("at_server_reload_start")
[docs] def at_post_portal_sync(self, mode): """ This is called just after the portal has finished syncing back data to the server after reconnecting. Args: mode (str): One of 'reload', 'reset' or 'shutdown'. """ from evennia.scripts.monitorhandler import MONITOR_HANDLER MONITOR_HANDLER.restore(mode == "reload") from evennia.scripts.tickerhandler import TICKER_HANDLER TICKER_HANDLER.restore(mode == "reload") # Un-pause all scripts, stop non-persistent timers evennia.ScriptDB.objects.update_scripts_after_server_start() # start the task handler from evennia.scripts.taskhandler import TASK_HANDLER TASK_HANDLER.load() TASK_HANDLER.create_delays() # create/update channels self.create_default_channels() # delete the temporary setting evennia.ServerConfig.objects.conf("server_restart_mode", delete=True)
[docs] def at_server_reload_stop(self): """ This is called only time the server stops before a reload. """ self._call_start_stop("at_server_reload_stop")
[docs] def at_server_cold_start(self): """ This is called only when the server starts "cold", i.e. after a shutdown or a reset. """ # We need to do this just in case the server was killed in a way where # the normal cleanup operations did not have time to run. from evennia.objects.models import ObjectDB ObjectDB.objects.clear_all_sessids() # Remove non-persistent scripts from evennia.scripts.models import ScriptDB for script in ScriptDB.objects.filter(db_persistent=False): script._stop_task() if settings.GUEST_ENABLED: for guest in evennia.AccountDB.objects.all().filter( db_typeclass_path=settings.BASE_GUEST_TYPECLASS ): for character in guest.db._playable_characters: if character: character.delete() guest.delete() self._call_start_stop("at_server_cold_start")
[docs] def at_server_cold_stop(self): """ This is called only when the server goes down due to a shutdown or reset. """ self._call_start_stop("at_server_cold_stop")