import asyncio import logging import os import re import shutil import signal import subprocess import sys from pathlib import Path from urllib.parse import urlparse from dotenv import load_dotenv import web from utils.log import RawConsoleSink, color_log_line, setup_logging from utils.vendor import check_for_updates # run.py needs LAVALINK_* to decide whether to supervise a node. bot.py loads # this again in its own process, which is a no-op the second time. load_dotenv() class _TeeStream: """Wraps stdout/stderr: writes go to the original stream and to the raw console sink. Replaces sys.stdout/sys.stderr wholesale so print(), logging, and traceback output all pass through it.""" def __init__(self, original, sink): self._original = original self._sink = sink def write(self, s): n = self._original.write(s) self._original.flush() self._sink.write(s.encode("utf-8", errors="replace")) return n def flush(self): self._original.flush() def __getattr__(self, name): return getattr(self._original, name) _console_sink = RawConsoleSink() # Rolls the last run's logs/console.raw over to .1. _real_stdout = sys.stdout # pre-wrap, used by the bot.py output pump sys.stdout = _TeeStream(sys.stdout, _console_sink) sys.stderr = _TeeStream(sys.stderr, _console_sink) # Must install the tee before setup_logging(): logging.StreamHandler() snapshots # sys.stderr at construction, so installing the tee later would bypass it. setup_logging(os.getenv("LOG_LEVEL", "INFO")) logger = logging.getLogger("run") class ProcessSupervisor: """Owns a child process: spawns it, watches it, and decides whether a dead child gets restarted (crash) or left alone (clean exit / user stop). Subclasses supply the command to run and how the child prefers to be stopped. """ # Used in log lines, e.g. "Starting bot...". label = "process" # Windows only. True for children that install their own SIGBREAK handler and # shut down cleanly on it. A JVM does not: CTRL_BREAK_EVENT makes it print a # thread dump and keep running, so Lavalink leaves this False. stops_on_ctrl_break = False # True for children whose output is rewritten by format_line(), which needs # whole lines. Children that render progress with \r must leave this False, # since line splitting would collapse those redraws. reformats_output = False def __init__(self, sink, host_stdout): self.process = None self.status = "stopped" # stopped | stopping | running | crashed_retrying self.sink = sink self.host_stdout = host_stdout self._lock = asyncio.Lock() def command(self) -> list[str]: raise NotImplementedError def cwd(self) -> str | None: return None def env(self) -> dict: return dict(os.environ) async def start(self): async with self._lock: if self.status in ("running", "crashed_retrying"): return False await self._spawn() return True async def stop(self): async with self._lock: proc = self.process if proc is None or proc.returncode is not None: self.status = "stopped" self.process = None return False # Set before _terminate, not after. Other tabs should see "stopping" # during the graceful shutdown instead of "stopped" too early. self.status = "stopping" await self._terminate(proc) self.status = "stopped" self.process = None return True async def restart(self): await self.stop() await self.start() def format_line(self, line: str) -> str | None: """Rewrites one line of the child's output, or returns None to drop it. Only called when reformats_output is set.""" return line async def _spawn(self): creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0 logger.info(f"Starting {self.label}...") self.process = await asyncio.create_subprocess_exec( *self.command(), creationflags=creationflags, env=self.env(), cwd=self.cwd(), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, # Raised from the 64KB default so a long line (a stack trace frame, # a dumped payload) can still be read whole in line mode. limit=1_048_576, ) self.status = "running" asyncio.create_task(self._pump_output(self.process)) asyncio.create_task(self._watch(self.process)) async def _terminate(self, proc): if sys.platform == "win32" and self.stops_on_ctrl_break: # The child is spawned in its own process group specifically so it can # be signalled independently of run.py's own console/Ctrl+C handling. proc.send_signal(signal.CTRL_BREAK_EVENT) else: proc.terminate() try: await asyncio.wait_for(proc.wait(), timeout=15) except asyncio.TimeoutError: proc.kill() await proc.wait() async def _pump_output(self, proc): """Forwards the child's merged stdout+stderr to the real host stdout and to the raw console sink. Reads in chunks, not lines, to preserve \\r bytes, unless the child's output is being reformatted.""" try: if self.reformats_output: async for raw_line in proc.stdout: line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n") formatted = self.format_line(line) if formatted is None: continue self._emit(f"{formatted}\n".encode("utf-8")) else: while chunk := await proc.stdout.read(4096): self._emit(chunk) except Exception: logger.exception(f"Error pumping {self.label} output") def _emit(self, data: bytes): self.host_stdout.buffer.write(data) self.host_stdout.buffer.flush() self.sink.write(data) async def _watch(self, proc): returncode = await proc.wait() if self.process is not proc: # Superseded by a newer spawn, e.g. stop+start raced the watcher. Ignore. return if self.status in ("stopped", "stopping"): # stop() is already handling this same proc via the same proc.wait(). # Let it own the status transition instead of racing it here. self.process = None return if returncode == 0: logger.info(f"{self.label.capitalize()} stopped cleanly (exit code 0). Not restarting.") self.status = "stopped" self.process = None return logger.warning(f"{self.label.capitalize()} stopped (exit code {returncode}). Restarting in 5 seconds...") self.status = "crashed_retrying" self.process = None await asyncio.sleep(5) async with self._lock: if self.status == "crashed_retrying": await self._spawn() class BotSupervisor(ProcessSupervisor): label = "bot" stops_on_ctrl_break = True def command(self): return [sys.executable, "bot.py"] def env(self): # Piped (not a real console) means PEP 528's UTF-8-for-console default no # longer applies; force UTF-8 explicitly to match what the sink expects. return {**os.environ, "PYTHONIOENCODING": "utf-8"} # Spring Boot's default console line, e.g. # 2026-09-06T15:19:56.714+01:00 INFO 6156 --- [Lavalink] [ main] o.s.b.w.e.u.UndertowWebServer : ... # The PID, the "---" separator and the app/thread brackets are all dropped in the # rewrite; only the timestamp, level, logger and message survive. _SPRING_LINE_RE = re.compile( r"^(?P\d{4})-(?P\d{2})-(?P\d{2})T(?P