mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
349 lines
10 KiB
349 lines
10 KiB
#!/usr/bin/env python3
|
|
"""SoulSync development launcher.
|
|
|
|
Starts the backend and Vite dev server together, restarts the backend when
|
|
backend source files change, and handles shutdown cleanly across platforms.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
ROOT_DIR = Path(__file__).resolve().parent
|
|
LOG_DIR = ROOT_DIR / 'logs'
|
|
GUNICORN_CONFIG = ROOT_DIR / 'gunicorn.dev.conf.py'
|
|
VITE_URL = os.environ.get('SOULSYNC_WEBUI_VITE_URL', 'http://127.0.0.1:5173').rstrip('/')
|
|
VITE_LOG_FILE = Path(os.environ.get('SOULSYNC_WEBUI_VITE_LOG', str(LOG_DIR / 'webui-vite.log')))
|
|
|
|
INCLUDED_SUFFIXES = {'.py', '.html', '.jinja', '.jinja2'}
|
|
SHUTDOWN_GRACE_SECONDS = int(os.environ.get('SOULSYNC_SHUTDOWN_GRACE_SECONDS', '10'))
|
|
FORCE_KILL_ON_SHUTDOWN = os.environ.get('SOULSYNC_FORCE_KILL_ON_SHUTDOWN', '1').lower() in {
|
|
'1',
|
|
'true',
|
|
'yes',
|
|
'on',
|
|
}
|
|
|
|
shutdown_requested = False
|
|
managed_processes: list[tuple[str, subprocess.Popen, object | None]] = []
|
|
|
|
|
|
def resolve_command(*candidates: str) -> str | None:
|
|
for candidate in candidates:
|
|
resolved = shutil.which(candidate)
|
|
if resolved:
|
|
return resolved
|
|
return None
|
|
|
|
|
|
def is_excluded(path: Path) -> bool:
|
|
try:
|
|
relative = path.relative_to(ROOT_DIR)
|
|
except ValueError:
|
|
return False
|
|
|
|
parts = relative.parts
|
|
if not parts:
|
|
return False
|
|
|
|
if any(part == '__pycache__' for part in parts):
|
|
return True
|
|
if parts[0] in {'.git', 'logs'}:
|
|
return True
|
|
if len(parts) >= 2 and parts[0] == 'webui' and parts[1] == 'node_modules':
|
|
return True
|
|
if len(parts) >= 3 and parts[0] == 'webui' and parts[1] == 'static' and parts[2] == 'dist':
|
|
return True
|
|
return False
|
|
|
|
|
|
def build_backend_env(direct_mode: bool) -> dict[str, str]:
|
|
env = os.environ.copy()
|
|
env.setdefault('SOULSYNC_WEB_DEV_NO_CACHE', '1')
|
|
env.setdefault('SOULSYNC_WEBUI_VITE_DEV', '1')
|
|
env.setdefault('SOULSYNC_WEBUI_VITE_URL', VITE_URL)
|
|
env.setdefault('SOULSYNC_WEBUI_VITE_LOG', str(VITE_LOG_FILE))
|
|
env.setdefault('SOULSYNC_CONFIG_PATH', str(ROOT_DIR / 'config' / 'config.json'))
|
|
|
|
if direct_mode:
|
|
env.setdefault('SOULSYNC_WEB_BIND_HOST', '127.0.0.1')
|
|
env.setdefault('SOULSYNC_WEB_BIND_PORT', '8008')
|
|
|
|
return env
|
|
|
|
|
|
def start_process(label: str, cmd: list[str], *, log_file: Path | None = None, env: dict[str, str] | None = None) -> tuple[subprocess.Popen, object | None]:
|
|
log_handle = None
|
|
stdout = None
|
|
stderr = None
|
|
if log_file is not None:
|
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
log_handle = log_file.open('ab')
|
|
stdout = log_handle
|
|
stderr = log_handle
|
|
|
|
creationflags = 0
|
|
start_new_session = False
|
|
if os.name == 'nt':
|
|
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
else:
|
|
start_new_session = True
|
|
|
|
try:
|
|
proc = subprocess.Popen(
|
|
cmd,
|
|
cwd=str(ROOT_DIR),
|
|
env=env,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=stdout,
|
|
stderr=stderr,
|
|
creationflags=creationflags,
|
|
start_new_session=start_new_session,
|
|
)
|
|
except Exception:
|
|
if log_handle is not None:
|
|
log_handle.close()
|
|
raise
|
|
|
|
managed_processes.append((label, proc, log_handle))
|
|
return proc, log_handle
|
|
|
|
|
|
def wait_for_exit(proc: subprocess.Popen, seconds: int) -> bool:
|
|
checks = max(1, int(seconds * 10))
|
|
for _ in range(checks):
|
|
if proc.poll() is not None:
|
|
return True
|
|
time.sleep(0.1)
|
|
return proc.poll() is not None
|
|
|
|
|
|
def stop_process(label: str, proc: subprocess.Popen, log_handle: object | None) -> None:
|
|
if proc.poll() is not None:
|
|
if log_handle is not None:
|
|
log_handle.close()
|
|
return
|
|
|
|
print(f'Stopping {label}...')
|
|
|
|
try:
|
|
if os.name == 'nt':
|
|
proc.terminate()
|
|
else:
|
|
os.killpg(proc.pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
if not wait_for_exit(proc, SHUTDOWN_GRACE_SECONDS):
|
|
if not FORCE_KILL_ON_SHUTDOWN:
|
|
print(f'{label} did not exit in time; skipping forced kill for this test run.')
|
|
else:
|
|
print(f'{label} did not exit in time; forcing shutdown...')
|
|
if os.name == 'nt':
|
|
subprocess.run(
|
|
['taskkill', '/T', '/F', '/PID', str(proc.pid)],
|
|
check=False,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
else:
|
|
try:
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
wait_for_exit(proc, 5)
|
|
|
|
if log_handle is not None:
|
|
log_handle.close()
|
|
|
|
|
|
def cleanup() -> None:
|
|
global shutdown_requested
|
|
|
|
if shutdown_requested:
|
|
return
|
|
shutdown_requested = True
|
|
|
|
for label, proc, log_handle in reversed(managed_processes):
|
|
stop_process(label, proc, log_handle)
|
|
|
|
|
|
def compute_backend_watch_state() -> str:
|
|
rows: list[str] = []
|
|
|
|
for dirpath, dirnames, filenames in os.walk(ROOT_DIR):
|
|
current_dir = Path(dirpath)
|
|
if is_excluded(current_dir):
|
|
dirnames[:] = []
|
|
continue
|
|
|
|
dirnames[:] = [
|
|
name
|
|
for name in dirnames
|
|
if not is_excluded(current_dir / name) and name != '__pycache__'
|
|
]
|
|
|
|
for filename in filenames:
|
|
path = current_dir / filename
|
|
if path.suffix not in INCLUDED_SUFFIXES:
|
|
continue
|
|
try:
|
|
stat = path.stat()
|
|
except FileNotFoundError:
|
|
continue
|
|
rows.append(f'{stat.st_mtime_ns} {path}')
|
|
|
|
return '\n'.join(sorted(rows))
|
|
|
|
|
|
def start_vite() -> subprocess.Popen:
|
|
npm = resolve_command('npm', 'npm.cmd')
|
|
if npm is None:
|
|
raise SystemExit('npm is required to run the Vite dev server.')
|
|
|
|
print(f'Starting Vite dev server at {VITE_URL}...')
|
|
vite_cmd = [
|
|
npm,
|
|
'--prefix',
|
|
str(ROOT_DIR / 'webui'),
|
|
'run',
|
|
'dev',
|
|
'--',
|
|
'--host',
|
|
'127.0.0.1',
|
|
'--port',
|
|
'5173',
|
|
]
|
|
proc, _ = start_process('Vite dev server', vite_cmd, log_file=VITE_LOG_FILE, env=os.environ.copy())
|
|
return proc
|
|
|
|
|
|
def wait_for_vite_ready(vite_proc: subprocess.Popen) -> None:
|
|
ready_url = f'{VITE_URL}/static/dist/@vite/client'
|
|
vite_ready = False
|
|
|
|
for _ in range(50):
|
|
if vite_proc.poll() is not None:
|
|
print('Warning: Vite dev server exited before it became ready.')
|
|
break
|
|
try:
|
|
with urllib.request.urlopen(ready_url, timeout=1) as response:
|
|
if response.status < 400:
|
|
vite_ready = True
|
|
break
|
|
except (urllib.error.URLError, TimeoutError, OSError):
|
|
pass
|
|
time.sleep(0.2)
|
|
|
|
if vite_ready:
|
|
print('Vite dev server is ready.')
|
|
else:
|
|
print('Warning: timed out waiting for the Vite dev server.')
|
|
print('The backend will still start, but the frontend may not hot-reload yet.')
|
|
|
|
|
|
def start_backend() -> tuple[subprocess.Popen, object | None]:
|
|
backend_mode = os.environ.get('SOULSYNC_DEV_BACKEND', '').strip().lower()
|
|
direct_mode = backend_mode == 'direct'
|
|
gunicorn_mode = backend_mode == 'gunicorn'
|
|
|
|
if not backend_mode:
|
|
if os.name == 'nt':
|
|
direct_mode = True
|
|
elif resolve_command('gunicorn') is None:
|
|
print('gunicorn not found; falling back to direct Python server.')
|
|
direct_mode = True
|
|
else:
|
|
gunicorn_mode = True
|
|
|
|
print('Starting SoulSync web server...')
|
|
|
|
if gunicorn_mode:
|
|
gunicorn = resolve_command('gunicorn')
|
|
if gunicorn is None:
|
|
raise SystemExit('gunicorn is not available but SOULSYNC_DEV_BACKEND=gunicorn was requested.')
|
|
print(f'Using Gunicorn config: {GUNICORN_CONFIG}')
|
|
cmd = [gunicorn, '-c', str(GUNICORN_CONFIG), 'wsgi:application']
|
|
else:
|
|
print('Using direct Python server for backend.')
|
|
cmd = [sys.executable, str(ROOT_DIR / 'web_server.py')]
|
|
|
|
proc, log_handle = start_process(
|
|
'SoulSync web server',
|
|
cmd,
|
|
env=build_backend_env(direct_mode),
|
|
)
|
|
return proc, log_handle
|
|
|
|
|
|
def watch_and_run_backend() -> None:
|
|
last_state = compute_backend_watch_state()
|
|
backend_proc, backend_log = start_backend()
|
|
|
|
try:
|
|
while not shutdown_requested:
|
|
time.sleep(1)
|
|
|
|
if backend_proc.poll() is not None:
|
|
print('SoulSync web server exited. Restarting...')
|
|
stop_process('SoulSync web server', backend_proc, backend_log)
|
|
managed_processes.pop()
|
|
backend_proc, backend_log = start_backend()
|
|
last_state = compute_backend_watch_state()
|
|
continue
|
|
|
|
current_state = compute_backend_watch_state()
|
|
if current_state != last_state:
|
|
print('Detected backend file changes. Restarting SoulSync web server...')
|
|
last_state = current_state
|
|
stop_process('SoulSync web server', backend_proc, backend_log)
|
|
managed_processes.pop()
|
|
backend_proc, backend_log = start_backend()
|
|
finally:
|
|
if backend_proc.poll() is None:
|
|
stop_process('SoulSync web server', backend_proc, backend_log)
|
|
if managed_processes:
|
|
managed_processes.pop()
|
|
|
|
|
|
def main() -> int:
|
|
if not (ROOT_DIR / 'webui' / 'node_modules').is_dir():
|
|
print('webui/node_modules is missing.')
|
|
print('Run: cd webui && npm ci')
|
|
return 1
|
|
|
|
vite_proc = start_vite()
|
|
try:
|
|
wait_for_vite_ready(vite_proc)
|
|
print(f'Vite log: {VITE_LOG_FILE}')
|
|
print('Backend file watching is enabled.')
|
|
watch_and_run_backend()
|
|
finally:
|
|
cleanup()
|
|
return 0
|
|
|
|
|
|
def _handle_signal(signum: int, _frame) -> None:
|
|
raise SystemExit(130 if signum == signal.SIGINT else 143)
|
|
|
|
|
|
signal.signal(signal.SIGINT, _handle_signal)
|
|
if hasattr(signal, 'SIGTERM'):
|
|
signal.signal(signal.SIGTERM, _handle_signal)
|
|
if hasattr(signal, 'SIGBREAK'):
|
|
signal.signal(signal.SIGBREAK, _handle_signal)
|
|
|
|
atexit.register(cleanup)
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|