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.
SoulSync/dev.py

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())