From 8f0cb8d80a44cba044c6e31d6df385f75709a7c6 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 6 Dec 2025 23:13:12 +0000 Subject: [PATCH] Better caddy startup handling, fix hang waiting for message that never comes. --- scripts/devserver.py | 139 +++++++++++++++++++++++++++++-------------- 1 file changed, 95 insertions(+), 44 deletions(-) diff --git a/scripts/devserver.py b/scripts/devserver.py index b041162..5840ff5 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -240,19 +240,73 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None: stderr.write(f"┃ ⚠️ Caddy couldn't start: {e}\n") return None + # Helper to parse Caddy log line (JSON or plain text) into (level, logger, msg) + def parse_caddy_log(line: str) -> tuple[str, str, str] | None: + """Parse a Caddy log line, return (level, logger, msg) or None if unparseable.""" + line = line.rstrip("\n") + if not line: + return None + + # Try JSON format first + try: + log = json.loads(line) + return ( + log.get("level", ""), + log.get("logger", ""), + log.get("msg", ""), + ) + except json.JSONDecodeError: + pass + + # Plain text format: "2025/12/06 22:59:41.390 INFO logger msg..." + # or "2025/12/06 22:59:41.390 INFO msg..." (no logger) + parts = line.split("\t") + if len(parts) >= 2: + # First part is "timestamp LEVEL", rest are logger and/or message + first = parts[0].rsplit(None, 1) # split off the level from timestamp + if len(first) == 2: + level = first[1].lower() + if len(parts) == 2: + return (level, "", parts[1]) + else: + return (level, parts[1], "\t".join(parts[2:])) + + # Unparseable - return as-is with no level/logger + return ("", "", line) + + def strip_caddy_verbose(msg: str) -> str: + """Remove verbose prefixes from Caddy error messages.""" + return msg.replace("loading initial config: loading new config: ", "") + + def format_caddy_log(level: str, logger: str, msg: str) -> str: + """Format a parsed Caddy log for display.""" + msg = strip_caddy_verbose(msg) + if logger: + return f"┃ [{level.upper()}] {logger}: {msg}\n" + else: + return f"┃ [{level.upper()}] {msg}\n" + # Read stderr line by line until Caddy signals it's ready or exits - # Caddy outputs JSON logs; "serving initial configuration" means it's ready + # Caddy outputs 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") + parsed = parse_caddy_log(line) + if parsed: + level, logger, msg = parsed + if level: + stderr.write(format_caddy_log(level, logger, msg)) + else: + stderr.write(f"┃ {strip_caddy_verbose(msg)}\n") + else: + stderr.write(f"┃ {strip_caddy_verbose(line)}\n") + stderr.write(f"┃ ⚠️ Caddy startup failed (exit code {exit_code})\n") return None # Read one line from stderr (blocks until data available) @@ -260,55 +314,52 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None: 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: + # Check for ready signal + if "serving initial configuration" in line: + ready = True break + parsed = parse_caddy_log(line) + if not parsed: + continue + + level, logger, msg = parsed + + # Filter out info-level and admin messages + if level == "info" or logger == "admin": + continue + + # Show errors/fatal to user + if level in ("error", "fatal"): + stderr.write(format_caddy_log(level, logger, msg)) + elif not level: + # Unparseable non-empty line (e.g., sudo prompt) - pass through with prefix + stderr.write(f"┃ {strip_caddy_verbose(msg)}\n") + stderr.flush() + # 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.upper()}] {logger}: {msg}\n") - else: - stderr.write(f"┃ [{level.upper()}] {msg}\n") - except json.JSONDecodeError: - stderr.write(line) + + parsed = parse_caddy_log(line) + if not parsed: + continue + + level, logger, msg = parsed + + # Filter out info-level and admin messages + if level == "info" or logger == "admin": + continue + + # Show errors/warnings to user + if level in ("error", "fatal", "warn"): + stderr.write(format_caddy_log(level, logger, msg)) + elif not level: + # Unparseable line - pass through with prefix + stderr.write(f"┃ {strip_caddy_verbose(msg)}\n") drain_thread = Thread(target=drain_stderr, daemon=True) drain_thread.start()