diff --git a/scripts/devserver.py b/scripts/devserver.py index c413a6d..b041162 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -28,7 +28,6 @@ import shutil import signal import subprocess import sys -import time from pathlib import Path from sys import stderr from threading import Thread @@ -241,37 +240,78 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None: stderr.write(f"┃ ⚠️ Caddy couldn't start: {e}\n") return None - # Wait briefly to check if Caddy exits immediately (e.g., port binding failure) - time.sleep(0.2) - exit_code = caddy_process.poll() - if exit_code is not None: - # Read and display stderr output, filtering and formatting JSON log lines - caddy_stderr = caddy_process.stderr.read().decode() if caddy_process.stderr else "" - stderr.write(f"┃ ⚠️ Caddy startup failed\n") - if caddy_stderr: - for line in caddy_stderr.splitlines(): - if not line: - continue - try: - log = json.loads(line) - # Filter out info-level messages - if log.get("level") == "info": - continue - # Filter out expected "admin endpoint disabled" warning - if log.get("logger") == "admin" and "admin endpoint disabled" in log.get("msg", ""): - continue - # Format: [level] logger: msg - level = log.get("level", "?").upper() - logger = log.get("logger", "") + # Read stderr line by line until Caddy signals it's ready or exits + # Caddy outputs JSON logs; "serving initial configuration" means it's ready + ready = False + while True: + exit_code = caddy_process.poll() + if exit_code is not None: + # Process exited - read remaining stderr and report failure + remaining = caddy_process.stderr.read().decode() if caddy_process.stderr else "" + stderr.write(f"┃ ⚠️ Caddy startup failed (exit code {exit_code})\n") + if remaining: + for line in remaining.splitlines(): + if line: + stderr.write(f"┃ {line}\n") + return None + + # Read one line from stderr (blocks until data available) + line = caddy_process.stderr.readline().decode() + if not line: + continue + + # Try to parse as JSON log + try: + log = json.loads(line) + msg = log.get("msg", "") + level = log.get("level", "") + + # Check for ready signal + if "serving initial configuration" in msg: + ready = True + break + + # Show errors/warnings to user + if level in ("error", "fatal"): + logger = log.get("logger", "") + if logger: + stderr.write(f"┃ [{level.upper()}] {logger}: {msg}\n") + else: + stderr.write(f"┃ [{level.upper()}] {msg}\n") + + except json.JSONDecodeError: + # Not JSON - might be sudo password prompt or other output + # Pass through to user + stderr.write(line) + stderr.flush() + + if ready: + break + + # Start a background thread to drain stderr and show errors + def drain_stderr(): + while True: + line = caddy_process.stderr.readline().decode() + if not line: + break + try: + log = json.loads(line) + level = log.get("level", "") + if level in ("error", "fatal", "warn"): msg = log.get("msg", "") + logger = log.get("logger", "") + # Skip expected warnings + if logger == "admin" and "admin endpoint disabled" in msg: + continue if logger: - stderr.write(f"┃ [{level}] {logger}: {msg}\n") + stderr.write(f"┃ [{level.upper()}] {logger}: {msg}\n") else: - stderr.write(f"┃ [{level}] {msg}\n") - except json.JSONDecodeError: - # Not JSON, print as-is (e.g., "Error: ..." lines) - stderr.write(f"┃ {line}\n") - return None + stderr.write(f"┃ [{level.upper()}] {msg}\n") + except json.JSONDecodeError: + stderr.write(line) + + drain_thread = Thread(target=drain_stderr, daemon=True) + drain_thread.start() def cleanup(): if caddy_process: