diff --git a/ahserver/__pycache__/configuredServer.cpython-310.pyc b/ahserver/__pycache__/configuredServer.cpython-310.pyc index d11c27e..689be3d 100644 Binary files a/ahserver/__pycache__/configuredServer.cpython-310.pyc and b/ahserver/__pycache__/configuredServer.cpython-310.pyc differ diff --git a/ahserver/configuredServer.py b/ahserver/configuredServer.py index 3e477b5..42589bf 100644 --- a/ahserver/configuredServer.py +++ b/ahserver/configuredServer.py @@ -2,6 +2,7 @@ import os,sys from sys import platform import time import ssl +import asyncio from socket import * from aiohttp import web @@ -21,6 +22,7 @@ from .serverenv import ServerEnv from .filestorage import TmpFileRecord from .loadplugins import load_plugins from .real_ip import real_ip_middleware +from .hotreload import HotReloader, get_i18n_paths, hot_reload_task startup_coros = [] cleanupctx_coros = [] @@ -110,17 +112,48 @@ class ConfiguredServer: if config.website.ssl: ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ssl_context.load_cert_chain(config.website.ssl.crtfile, - config.website.ssl.keyfile) + config.website.ssl.keyfile) reuse_port = None if platform != 'win32': reuse_port = True print('reuse_port=', reuse_port) [ self.app.on_startup.append(c) for c in startup_coros ] [ self.app.cleanup_ctx.append(c) for c in cleanupctx_coros ] + + # Hot reload setup + hot_reload_cfg = config.get('hot_reload', False) + if hot_reload_cfg: + interval = 2 + if isinstance(hot_reload_cfg, dict): + if not hot_reload_cfg.get('enabled', True): + hot_reload_cfg = False + else: + interval = hot_reload_cfg.get('interval', 2) + if hot_reload_cfg: + workdir = self.workdir or os.getcwd() + config_path = os.path.join(workdir, 'conf', 'config.json') + i18n_paths = get_i18n_paths(workdir) + reloader = HotReloader(config_path, i18n_paths) + reloader.set_interval(interval) + async def _hot_reload_startup(app): + task = asyncio.create_task(hot_reload_task(app, reloader)) + app['hot_reload_task'] = task + async def _hot_reload_cleanup(app): + task = app.get('hot_reload_task') + if task: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + self.app.on_startup.append(_hot_reload_startup) + self.app.cleanup_ctx.append(_hot_reload_cleanup) + print(f'hot_reload enabled, interval={interval}s, pid will check independently') + web.run_app(self.build_app(),host=config.website.host or '0.0.0.0', - port=port, - reuse_port=reuse_port, - ssl_context=ssl_context) + port=port, + reuse_port=reuse_port, + ssl_context=ssl_context) def configPath(self,config): for p,prefix in config.website.paths: diff --git a/ahserver/hotreload.py b/ahserver/hotreload.py new file mode 100644 index 0000000..8716b4c --- /dev/null +++ b/ahserver/hotreload.py @@ -0,0 +1,170 @@ +""" +ahserver hot-reload module. + +Watches file mtimes and triggers reload of cached resources: +- config.json (JsonConfig singleton) +- i18n files (MiniI18N singleton) +- Jinja2 template cache (auto_reload already handles this) + +Multi-process safe: each process independently checks mtimes via stat(), +no cross-process coordination needed. When a file changes on disk, +all processes detect it on their next check cycle. + +Usage in conf/config.json: +{ + "hot_reload": { + "enabled": true, + "interval": 2 + } +} + +Or simply: +{ + "hot_reload": true +} +""" + +import os +import time +import asyncio +from appPublic.log import info, debug, warning + + +class FileWatcher: + """Track file mtimes and detect changes.""" + + def __init__(self): + self._mtimes = {} + + def check(self, paths): + """Check if any of the given paths have changed. + + Args: + paths: list of absolute file paths to watch + + Returns: + list of changed file paths (empty if none changed) + """ + changed = [] + for path in paths: + try: + mtime = os.path.getmtime(path) + except OSError: + continue + old_mtime = self._mtimes.get(path) + if old_mtime is None: + self._mtimes[path] = mtime + elif mtime > old_mtime: + self._mtimes[path] = mtime + changed.append(path) + return changed + + +class HotReloader: + """Hot-reload cached resources when source files change. + + Each process runs its own HotReloader instance. Since all processes + share the same filesystem, mtime-based detection works independently + in each process without any cross-process communication. + """ + + def __init__(self, config_path, i18n_paths=None): + self._watcher = FileWatcher() + self._config_path = config_path + self._i18n_paths = i18n_paths or [] + self._last_check = 0 + self._interval = 2 # seconds between checks + + def set_interval(self, interval): + self._interval = interval + + def _should_check(self): + now = time.time() + if now - self._last_check < self._interval: + return False + self._last_check = now + return True + + def check_and_reload(self): + """Check for file changes and reload if needed. + + Returns: + dict with keys 'config', 'i18n' indicating what was reloaded + """ + if not self._should_check(): + return {} + + reloaded = {} + + # Check config.json + config_changed = self._watcher.check([self._config_path]) + if config_changed: + self._reload_config() + reloaded['config'] = True + + # Check i18n files + if self._i18n_paths: + i18n_changed = self._watcher.check(self._i18n_paths) + if i18n_changed: + self._reload_i18n() + reloaded['i18n'] = True + + return reloaded + + def _reload_config(self): + """Clear JsonConfig singleton so next getConfig() call reloads from disk.""" + try: + from appPublic.jsonConfig import JsonConfig + # SingletonDecorator stores instance as .instance + JsonConfig.instance = None + info('[hot_reload] config.json changed, cache cleared') + except Exception as e: + warning(f'[hot_reload] failed to reload config: {e}') + + def _reload_i18n(self): + """Clear MiniI18N singleton and ServerEnv cache.""" + try: + from appPublic.i18n import MiniI18N + MiniI18N.instance = None + # Clear cached i18n on ServerEnv + try: + from .serverenv import ServerEnv + g = ServerEnv() + if hasattr(g, 'myi18n'): + del g.myi18n + except Exception: + pass + info('[hot_reload] i18n files changed, cache cleared') + except Exception as e: + warning(f'[hot_reload] failed to reload i18n: {e}') + + +def get_i18n_paths(workdir=None): + """Collect all i18n msg.txt file paths for watching.""" + if workdir is None: + workdir = os.getcwd() + i18n_dir = os.path.join(workdir, 'i18n') + paths = [] + if os.path.isdir(i18n_dir): + for lang in os.listdir(i18n_dir): + msg_file = os.path.join(i18n_dir, lang, 'msg.txt') + if os.path.isfile(msg_file): + paths.append(msg_file) + return paths + + +async def hot_reload_task(app, reloader): + """Background task that periodically checks for file changes. + + Added to app.on_startup when hot_reload is enabled. + """ + info(f'[hot_reload] started, interval={reloader._interval}s') + try: + while True: + await asyncio.sleep(reloader._interval) + reloaded = reloader.check_and_reload() + if reloaded: + info(f'[hot_reload] reloaded: {list(reloaded.keys())}') + except asyncio.CancelledError: + info('[hot_reload] stopped') + raise