Get hot reloading working again
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import importlib
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import logging
|
||||
log = logging.getLogger('werkzeug')
|
||||
@@ -17,7 +19,7 @@ CORS(app)
|
||||
|
||||
from munch import Munch
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import PatternMatchingEventHandler
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
|
||||
from mosfet import connect
|
||||
|
||||
@@ -46,6 +48,7 @@ g.afk = False
|
||||
g.health = 20
|
||||
g.food = 20
|
||||
g.sand_origin = None
|
||||
g.reload_lock = threading.RLock()
|
||||
|
||||
@app.route('/api/global')
|
||||
def api_global():
|
||||
@@ -63,62 +66,177 @@ def root():
|
||||
return app.send_static_file('index.html')
|
||||
|
||||
def main():
|
||||
"""Connect with offline/AuthMe and enable only the migrated 26.3 features.
|
||||
"""Run the 26.3 client with in-place development hot reload.
|
||||
|
||||
Current gameplay support includes path following and a crop-only harvest /
|
||||
replant job. General inventory actions, other block interactions, and
|
||||
legacy jobs remain disabled until migrated.
|
||||
Application modules are refreshed while the current socket and player
|
||||
session remain alive. Connection/bootstrap and packet-schema edits are
|
||||
reported as deferred because their listeners cannot safely be replaced
|
||||
without reconnecting.
|
||||
"""
|
||||
reload_timeout = time.time()
|
||||
reload_requested = False
|
||||
|
||||
def reload_modules():
|
||||
manager_module = sys.modules.get('mosfet.protocol.managers')
|
||||
if manager_module is not None:
|
||||
importlib.reload(manager_module)
|
||||
bot_module = sys.modules.get('mosfet.bot')
|
||||
if bot_module is not None:
|
||||
importlib.reload(bot_module)
|
||||
importlib.reload(connect)
|
||||
|
||||
def reload_bot(event):
|
||||
nonlocal reload_timeout, reload_requested
|
||||
if time.time() - reload_timeout > 2.0:
|
||||
reload_timeout = time.time()
|
||||
reload_requested = True
|
||||
print('Reloading...')
|
||||
g.running = False
|
||||
|
||||
g.running = True
|
||||
changes = queue.Queue()
|
||||
observer = Observer()
|
||||
event_handler = PatternMatchingEventHandler(
|
||||
patterns=['*.py'], ignore_patterns=['./mosfet.py'])
|
||||
event_handler.on_any_event = reload_bot
|
||||
observer.schedule(event_handler, 'mosfet', recursive=True)
|
||||
source_root = Path(__file__).resolve().parent / 'mosfet'
|
||||
|
||||
def enqueue_change(event):
|
||||
if event.is_directory:
|
||||
return
|
||||
path = getattr(event, 'dest_path', None) if event.event_type == 'moved' else event.src_path
|
||||
if not path or Path(path).suffix != '.py' or '__pycache__' in path:
|
||||
return
|
||||
changes.put(str(Path(path).resolve()))
|
||||
|
||||
def module_name(path):
|
||||
source = Path(path)
|
||||
try:
|
||||
relative = source.relative_to(source_root)
|
||||
except ValueError:
|
||||
return None
|
||||
parts = list(relative.with_suffix('').parts)
|
||||
if parts and parts[-1] == '__init__':
|
||||
parts.pop()
|
||||
return '.'.join(('mosfet', *parts))
|
||||
|
||||
def reload_changed(paths):
|
||||
paths = sorted(set(path for path in paths if Path(path).is_file()))
|
||||
if not paths:
|
||||
return
|
||||
|
||||
# Don't execute a half-written editor save. If any file is syntactically
|
||||
# invalid, keep the currently running code untouched.
|
||||
try:
|
||||
for path in paths:
|
||||
source = Path(path)
|
||||
compile(source.read_text(encoding='utf-8'), str(source), 'exec')
|
||||
except (OSError, SyntaxError, UnicodeError):
|
||||
print('[hot reload] waiting for a valid save', flush=True)
|
||||
return
|
||||
|
||||
deferred = []
|
||||
modules = []
|
||||
for path in paths:
|
||||
name = module_name(path)
|
||||
if not name:
|
||||
continue
|
||||
if name in ('mosfet.connect', 'mosfet') or name == 'mosfet.monkey_patch':
|
||||
deferred.append(name)
|
||||
else:
|
||||
modules.append(name)
|
||||
if deferred:
|
||||
print('[hot reload] {} changed; connection/bootstrap changes are '
|
||||
'deferred until the next start (no disconnect performed)'.format(
|
||||
', '.join(sorted(set(deferred)))), flush=True)
|
||||
if not modules:
|
||||
return
|
||||
if not g.get('connected') or not g.get('commands'):
|
||||
deadline = time.monotonic() + 15.0
|
||||
while (g.running and time.monotonic() < deadline and
|
||||
(not g.get('connected') or not g.get('commands'))):
|
||||
time.sleep(0.1)
|
||||
if not g.get('connected') or not g.get('commands'):
|
||||
print('[hot reload] runtime not ready; change left for next save',
|
||||
flush=True)
|
||||
return
|
||||
|
||||
managers_changed = bool(
|
||||
{'mosfet.protocol.managers', 'mosfet.protocol.types'} & set(modules))
|
||||
core_modules = {
|
||||
'mosfet.bot', 'mosfet.commands', 'mosfet.game', 'mosfet.job',
|
||||
'mosfet.path', 'mosfet.print_help', 'mosfet.utils',
|
||||
'mosfet.vector', 'mosfet.world', 'mosfet.info.blocks',
|
||||
'mosfet.info.items', 'mosfet.info.mcdata', 'mosfet.info.mobs',
|
||||
}
|
||||
# bot.py reloads the legacy gameplay modules and job state classes as
|
||||
# one coherent group. Reload protocol adapters first so rebinding sees
|
||||
# their latest definitions.
|
||||
direct_modules = [name for name in modules
|
||||
if name not in core_modules and
|
||||
not name.startswith('mosfet.jobs.')]
|
||||
if managers_changed and 'mosfet.protocol.managers' not in direct_modules:
|
||||
direct_modules.append('mosfet.protocol.managers')
|
||||
priority = {
|
||||
'mosfet.protocol.types': 0,
|
||||
'mosfet.protocol.packets': 1,
|
||||
'mosfet.protocol.managers': 2,
|
||||
'mosfet.protocol.modern': 3,
|
||||
}
|
||||
direct_modules.sort(key=lambda name: (priority.get(name, 10), name))
|
||||
|
||||
connection = g.connection
|
||||
packet_lock = getattr(connection, 'packet_listener_lock', None)
|
||||
try:
|
||||
if packet_lock is None:
|
||||
with g.reload_lock:
|
||||
apply_reload(direct_modules, managers_changed)
|
||||
else:
|
||||
# Match packet dispatch's lock order (listener lock, then the
|
||||
# runtime lock) so callbacks cannot race listener replacement.
|
||||
with packet_lock:
|
||||
with g.reload_lock:
|
||||
apply_reload(direct_modules, managers_changed)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
print('[hot reload] failed; connection left running', flush=True)
|
||||
|
||||
def apply_reload(direct_modules, managers_changed):
|
||||
for name in direct_modules:
|
||||
module = sys.modules.get(name)
|
||||
if module is not None:
|
||||
importlib.reload(module)
|
||||
bot_module = sys.modules.get('mosfet.bot')
|
||||
if bot_module is None:
|
||||
bot_module = importlib.import_module('mosfet.bot')
|
||||
bot_module = importlib.reload(bot_module)
|
||||
bot_module.refresh_runtime(global_state,
|
||||
reload_managers=managers_changed)
|
||||
print('[hot reload] refreshed {} (session preserved)'.format(
|
||||
', '.join(direct_modules) if direct_modules else 'bot runtime'),
|
||||
flush=True)
|
||||
|
||||
def reload_worker():
|
||||
while True:
|
||||
path = changes.get()
|
||||
if path is None:
|
||||
return
|
||||
batch = {path}
|
||||
stopping = False
|
||||
# Coalesce the burst of modified/moved events from editor saves.
|
||||
while True:
|
||||
try:
|
||||
candidate = changes.get(timeout=0.4)
|
||||
except queue.Empty:
|
||||
break
|
||||
if candidate is None:
|
||||
stopping = True
|
||||
break
|
||||
batch.add(candidate)
|
||||
time.sleep(0.15)
|
||||
reload_changed(batch)
|
||||
if stopping:
|
||||
return
|
||||
|
||||
event_handler = FileSystemEventHandler()
|
||||
event_handler.on_modified = enqueue_change
|
||||
event_handler.on_created = enqueue_change
|
||||
event_handler.on_moved = enqueue_change
|
||||
observer.schedule(event_handler, str(source_root), recursive=True)
|
||||
observer.start()
|
||||
worker = threading.Thread(target=reload_worker,
|
||||
name='mosfet-hot-reloader', daemon=True)
|
||||
worker.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
g.running = True
|
||||
connect.main(global_state)
|
||||
if not reload_requested:
|
||||
break
|
||||
reload_requested = False
|
||||
reload_modules()
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except BaseException:
|
||||
g.running = True
|
||||
traceback.print_exc()
|
||||
print('Locking...')
|
||||
while g.running:
|
||||
time.sleep(1)
|
||||
reload_requested = False
|
||||
reload_modules()
|
||||
connect.main(global_state)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except BaseException:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
g.running = False
|
||||
changes.put(None)
|
||||
observer.stop()
|
||||
observer.join()
|
||||
worker.join(timeout=2.0)
|
||||
|
||||
def run_api():
|
||||
host = '0.0.0.0'
|
||||
|
||||
Reference in New Issue
Block a user