212 lines
7.3 KiB
Python
212 lines
7.3 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# Standard
|
|
from pathlib import Path
|
|
import atexit
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
|
|
# First Party
|
|
from lmcache.logging import init_logger
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
class RuntimePluginLauncher:
|
|
def __init__(
|
|
self,
|
|
config,
|
|
role: str | None,
|
|
worker_count: int,
|
|
worker_id: int,
|
|
):
|
|
self.config = config
|
|
self.role = role
|
|
self.worker_count = worker_count
|
|
self.worker_id = worker_id
|
|
self.plugin_processes: list[subprocess.Popen] = []
|
|
# Register cleanup handler
|
|
atexit.register(self.stop_plugins)
|
|
|
|
def launch_plugins(self):
|
|
"""Launch all configured plugins"""
|
|
if not self.config.runtime_plugin_locations:
|
|
return
|
|
|
|
for loc in self.config.runtime_plugin_locations:
|
|
self._launch_plugins(loc)
|
|
|
|
def _launch_plugins(self, loc: str):
|
|
"""Launch plugins from specified location"""
|
|
path = Path(loc)
|
|
if not path.exists():
|
|
logger.warning(f"Runtime plugin location {loc} does not exist")
|
|
return
|
|
|
|
files = []
|
|
if path.is_file():
|
|
files = [path]
|
|
elif path.is_dir():
|
|
# Recursively find all .py and .sh files
|
|
for ext in ["*.py", "*.sh"]:
|
|
files.extend(path.rglob(ext))
|
|
|
|
for file in files:
|
|
self._launch_plugin(file)
|
|
|
|
def _should_skip_plugin(self, file: Path, parts: list[str]) -> bool:
|
|
"""Determine if plugin should be skipped based on role/worker ID.
|
|
|
|
The naming convention is ROLE_WORKERID_NAME (e.g. server_0_foo.py).
|
|
When role is None (MP mode), this convention does not apply,
|
|
so both role and worker ID filtering are skipped entirely.
|
|
"""
|
|
if len(parts) < 2:
|
|
return False
|
|
|
|
# When role is None, skip all role/worker-based filtering
|
|
# (e.g. MP mode has no role concept)
|
|
if self.role is None:
|
|
return False
|
|
|
|
plugin_role = parts[0].upper()
|
|
if plugin_role != "ALL" and plugin_role != self.role.upper():
|
|
logger.info(
|
|
"Skipping %s: requires role %s",
|
|
file,
|
|
plugin_role,
|
|
)
|
|
return True
|
|
|
|
# Check worker ID match (parts[1] in ROLE_WORKERID_NAME)
|
|
if len(parts) > 2 and parts[1].isdigit():
|
|
plugin_worker_id = int(parts[1])
|
|
if plugin_worker_id != self.worker_id:
|
|
logger.info(
|
|
"worker %d is skipping plugin %s, which is only for worker ID %d",
|
|
self.worker_id,
|
|
file,
|
|
plugin_worker_id,
|
|
)
|
|
return True
|
|
|
|
return False
|
|
|
|
def _launch_plugin(self, file: Path):
|
|
"""Launch a plugin"""
|
|
try:
|
|
filename = file.stem.lower()
|
|
parts = filename.split("_")
|
|
|
|
if self._should_skip_plugin(file, parts):
|
|
return
|
|
|
|
# Get interpreter from first line (shebang)
|
|
interpreter = self._get_interpreter(file)
|
|
|
|
# Pass role and config as environment variables
|
|
env = os.environ.copy()
|
|
role_str = self.role or ""
|
|
env["LMCACHE_RUNTIME_PLUGIN_ROLE"] = role_str
|
|
env["LMCACHE_RUNTIME_PLUGIN_CONFIG"] = self.config.to_json()
|
|
env["LMCACHE_RUNTIME_PLUGIN_WORKER_COUNT"] = str(self.worker_count)
|
|
env["LMCACHE_RUNTIME_PLUGIN_WORKER_ID"] = str(self.worker_id)
|
|
|
|
# TODO: For backwards compatibility, remove when applicable
|
|
env["LMCACHE_PLUGIN_ROLE"] = role_str
|
|
env["LMCACHE_PLUGIN_CONFIG"] = self.config.to_json()
|
|
env["LMCACHE_PLUGIN_WORKER_COUNT"] = str(self.worker_count)
|
|
env["LMCACHE_PLUGIN_WORKER_ID"] = str(self.worker_id)
|
|
|
|
# Force line-buffered stdout for Python sub-processes
|
|
# so that output is captured in real-time rather than
|
|
# being held in the block buffer until exit.
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
|
|
proc = subprocess.Popen(
|
|
[interpreter, str(file)],
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
self.plugin_processes.append(proc)
|
|
logger.info(f"Launched runtime plugin: {file} with {interpreter}")
|
|
|
|
# Start thread to capture output continuously
|
|
threading.Thread(
|
|
target=self._capture_plugin_output, args=(proc, str(file)), daemon=True
|
|
).start()
|
|
except Exception as e:
|
|
logger.error(f"Failed to launch plugin {file}: {e}")
|
|
|
|
def _get_interpreter(self, file: Path) -> str:
|
|
"""Get interpreter from first line comment"""
|
|
interpreters = []
|
|
try:
|
|
with open(file, "r", encoding="utf-8") as f:
|
|
first_line = f.readline().strip()
|
|
if first_line.startswith("#!"):
|
|
# Extract interpreter
|
|
interpreter_str = first_line[2:].strip()
|
|
interpreters.append(interpreter_str)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error reading interpreter from runtime plugin file {file} - "
|
|
f"using default interpreters: {e}"
|
|
)
|
|
pass
|
|
|
|
# Fallback to default interpreters
|
|
if file.suffix == ".py":
|
|
interpreters.append("python")
|
|
interpreters.append("python3")
|
|
elif file.suffix == ".sh":
|
|
interpreters.append("bash")
|
|
else:
|
|
raise ValueError(f"Plugin type {file.suffix} not supported ")
|
|
|
|
# Try each interpreter until we find one that exists
|
|
for interpreter in interpreters:
|
|
interpreter = interpreter.strip()
|
|
resolved_interpreter = shutil.which(interpreter)
|
|
if resolved_interpreter:
|
|
return resolved_interpreter
|
|
|
|
raise ValueError(f"No valid interpreter found for {file} from {interpreters}")
|
|
|
|
def _capture_plugin_output(self, proc: subprocess.Popen, plugin_name: str):
|
|
"""Continuously capture and log plugin output"""
|
|
try:
|
|
assert proc.stdout is not None, (
|
|
"The runtime plugin subprocess does not have stdout"
|
|
)
|
|
while True:
|
|
line = proc.stdout.readline()
|
|
if not line:
|
|
break
|
|
logger.info(f"[{plugin_name}] {line.strip()}")
|
|
|
|
proc.wait()
|
|
logger.info(
|
|
f"Runtime plugin {plugin_name} exited with code {proc.returncode}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error capturing output for {plugin_name}: {e}")
|
|
|
|
def stop_plugins(self):
|
|
"""Terminate all plugin processes.
|
|
|
|
Note: This method may be called via atexit during interpreter shutdown,
|
|
when the logging system may already be closed. We avoid using logger
|
|
here to prevent "I/O operation on closed file" errors.
|
|
"""
|
|
for proc in self.plugin_processes:
|
|
try:
|
|
if proc.poll() is None:
|
|
proc.terminate()
|
|
except Exception:
|
|
# Silently ignore errors during shutdown
|
|
pass
|