diff --git a/README.md b/README.md index a26f61a3..787fa968 100644 --- a/README.md +++ b/README.md @@ -307,8 +307,8 @@ Run tests separately when needed: python -m pytest ``` -If you want a convenience launcher, `./dev.sh` starts both halves together. -It is most useful on Linux, macOS, and WSL. +If you want a convenience launcher, `python dev.py` starts both halves together +on any OS. `./dev.sh` remains available as a Unix shell wrapper. --- diff --git a/dev.py b/dev.py new file mode 100755 index 00000000..a087b7bc --- /dev/null +++ b/dev.py @@ -0,0 +1,348 @@ +#!/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 install') + 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()) diff --git a/dev.sh b/dev.sh index 7619ee4b..fffb87fd 100755 --- a/dev.sh +++ b/dev.sh @@ -1,235 +1,16 @@ -#!/bin/bash -# SoulSync Development Launcher Script -# Starts the Python backend and Vite dev server together for local work. -set -euo pipefail +#!/bin/sh +# Compatibility wrapper for the Python launcher. +set -e -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR" -mkdir -p "$SCRIPT_DIR/logs" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -DEV_GUNICORN_CONFIG="$SCRIPT_DIR/gunicorn.dev.conf.py" -GUNICORN_CONFIG="$DEV_GUNICORN_CONFIG" - -VITE_URL="${SOULSYNC_WEBUI_VITE_URL:-http://127.0.0.1:5173}" -VITE_LOG_FILE="${SOULSYNC_WEBUI_VITE_LOG:-$SCRIPT_DIR/logs/webui-vite.log}" - -VITE_PID="" -SERVER_PID="" -SHUTTING_DOWN="0" -SHUTDOWN_GRACE_SECONDS="${SOULSYNC_SHUTDOWN_GRACE_SECONDS:-10}" -FORCE_KILL_ON_SHUTDOWN="${SOULSYNC_FORCE_KILL_ON_SHUTDOWN:-1}" - -stop_process_group() { - local pid="$1" - local label="$2" - - if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then - return - fi - - echo "Stopping ${label}..." - kill -TERM -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true - - local max_checks=$((SHUTDOWN_GRACE_SECONDS * 10)) - for _ in $(seq 1 "$max_checks"); do - if ! kill -0 "$pid" 2>/dev/null; then - break - fi - sleep 0.1 - done - - if kill -0 "$pid" 2>/dev/null; then - if [[ "$FORCE_KILL_ON_SHUTDOWN" != "1" ]]; then - echo "${label} did not exit in time; skipping forced kill for this test run." - wait "$pid" 2>/dev/null || true - return - fi - echo "${label} did not exit in time; forcing shutdown..." - kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true - fi - - wait "$pid" 2>/dev/null || true -} - -cleanup() { - if [[ "$SHUTTING_DOWN" == "1" ]]; then - return - fi - SHUTTING_DOWN="1" - - stop_process_group "${SERVER_PID}" "SoulSync web server" - stop_process_group "${VITE_PID}" "Vite dev server" -} - -trap cleanup EXIT INT TERM - -start_in_own_session() { - local pid_file="$1" - shift - - local log_file="" - if [[ "${1:-}" == "--log-file" ]]; then - log_file="$2" - shift 2 - fi - - python3 - "$pid_file" "$log_file" "$@" <<'PY' -import subprocess -import sys - -pid_file = sys.argv[1] -log_file = sys.argv[2] -cmd = sys.argv[3:] -stdout = None -stderr = None -log_handle = None - -if log_file: - log_handle = open(log_file, "ab") - stdout = log_handle - stderr = log_handle - -try: - process = subprocess.Popen(cmd, start_new_session=True, stdout=stdout, stderr=stderr) - with open(pid_file, "w", encoding="utf-8") as pid_handle: - pid_handle.write(str(process.pid)) -finally: - if log_handle is not None: - log_handle.close() -PY -} - -start_server() { - echo "Starting SoulSync web server..." - echo "Using Gunicorn config: ${GUNICORN_CONFIG}" - local pid_file - pid_file="$(mktemp "$SCRIPT_DIR/logs/.gunicorn-pid.XXXXXX")" - start_in_own_session "$pid_file" gunicorn -c "${GUNICORN_CONFIG}" wsgi:application - SERVER_PID="$(<"$pid_file")" - rm -f "$pid_file" -} - -stop_server() { - stop_process_group "${SERVER_PID}" "SoulSync web server" - SERVER_PID="" -} - -compute_backend_watch_state() { - python3 - "$SCRIPT_DIR" <<'PY' -import os -import sys -from pathlib import Path - -root = Path(sys.argv[1]).resolve() -excluded_dirs = { - root / '.git', - root / 'logs', - root / 'webui' / 'node_modules', - root / 'webui' / 'static' / 'dist', -} -included_suffixes = {'.py', '.html', '.jinja', '.jinja2'} -rows = [] - -for dirpath, dirnames, filenames in os.walk(root): - current_dir = Path(dirpath) - if current_dir in excluded_dirs: - dirnames[:] = [] - continue - if any(part == '__pycache__' for part in current_dir.parts): - dirnames[:] = [] - continue - - dirnames[:] = [ - name - for name in dirnames - if (current_dir / name) not in excluded_dirs and name != '__pycache__' - ] - - for filename in filenames: - path = current_dir / filename - if any(part == '__pycache__' for part in path.parts): - continue - if path.suffix not in included_suffixes: - continue - try: - stat = path.stat() - except FileNotFoundError: - continue - rows.append(f'{stat.st_mtime_ns} {path}') - -for row in sorted(rows): - print(row) -PY -} - -watch_and_run_server() { - local last_state="" - local current_state="" - - last_state="$(compute_backend_watch_state)" - start_server - - while true; do - sleep 1 - - if [[ "$SHUTTING_DOWN" == "1" ]]; then - return - fi - - if [[ -n "${SERVER_PID}" ]] && ! kill -0 "${SERVER_PID}" 2>/dev/null; then - echo "SoulSync web server exited. Restarting..." - start_server - last_state="$(compute_backend_watch_state)" - continue - fi - - current_state="$(compute_backend_watch_state)" - if [[ "$current_state" != "$last_state" ]]; then - echo "Detected backend file changes. Restarting SoulSync web server..." - last_state="$current_state" - stop_server - start_server - fi - done -} - -if [[ ! -d "$SCRIPT_DIR/webui/node_modules" ]]; then - echo "webui/node_modules is missing." - echo "Run: cd webui && npm install" - exit 1 +if command -v python3 >/dev/null 2>&1; then + exec python3 "$SCRIPT_DIR/dev.py" "$@" fi -echo "Starting Vite dev server at ${VITE_URL}..." -mkdir -p "$(dirname "$VITE_LOG_FILE")" -VITE_PID_FILE="$(mktemp "$SCRIPT_DIR/logs/.vite-pid.XXXXXX")" -start_in_own_session "$VITE_PID_FILE" --log-file "$VITE_LOG_FILE" npm --prefix "$SCRIPT_DIR/webui" run dev -- --host 127.0.0.1 --port 5173 -VITE_PID="$(<"$VITE_PID_FILE")" -rm -f "$VITE_PID_FILE" - -if command -v curl >/dev/null 2>&1; then - READY_URL="${VITE_URL}/static/dist/@vite/client" - vite_ready="0" - for _ in {1..50}; do - if ! kill -0 "${VITE_PID}" 2>/dev/null; then - echo "Warning: Vite dev server exited before it became ready." - break - fi - if curl -fsS "$READY_URL" >/dev/null 2>&1; then - vite_ready="1" - break - fi - sleep 0.2 - done - if [[ "$vite_ready" == "1" ]]; then - echo "Vite dev server is ready." - else - echo "Warning: timed out waiting for the Vite dev server." - echo "The backend will still start, but the frontend may not hot-reload yet." - fi -else - sleep 2 +if command -v python >/dev/null 2>&1; then + exec python "$SCRIPT_DIR/dev.py" "$@" fi -echo "Vite log: $VITE_LOG_FILE" -echo "Backend file watching is enabled." -watch_and_run_server +echo "Python is required to run the SoulSync dev launcher." +exit 1 diff --git a/web_server.py b/web_server.py index 36d18651..d4449315 100644 --- a/web_server.py +++ b/web_server.py @@ -35896,8 +35896,11 @@ def start_runtime_services(): # Direct execution: python web_server.py (dev/Windows fallback) # Production should use: gunicorn -c gunicorn.conf.py wsgi:application if _DIRECT_RUN: + web_run_host = os.environ.get('SOULSYNC_WEB_BIND_HOST', '0.0.0.0') + web_run_port = int(os.environ.get('SOULSYNC_WEB_BIND_PORT', '8008')) + display_host = '127.0.0.1' if web_run_host in {'0.0.0.0', '::'} else web_run_host logger.info("Starting SoulSync Web UI Server...") - logger.info("Open your browser and navigate to http://127.0.0.1:8008") + logger.info(f"Open your browser and navigate to http://{display_host}:{web_run_port}") logger.info("Tip: For production, use gunicorn -c gunicorn.conf.py wsgi:application") start_runtime_services() - socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True) + socketio.run(app, host=web_run_host, port=web_run_port, debug=False, allow_unsafe_werkzeug=True) diff --git a/webui/README.md b/webui/README.md index fb63eb61..1e025715 100644 --- a/webui/README.md +++ b/webui/README.md @@ -89,5 +89,5 @@ npm run check npm run fix ``` -If you want a convenience wrapper, the repo root also includes `./dev.sh`. -It starts both halves together and is most useful on Linux, macOS, and WSL. +If you want a convenience launcher, the repo root includes `python dev.py` +for any OS and `./dev.sh` as a Unix shell wrapper.