"""
Utilities for detecting and managing Scala Metals H2 database state.
This module provides functions to detect existing Metals LSP instances by checking
the H2 database lock file, and to clean up stale locks from crashed processes.
Metals uses H2 AUTO_SERVER mode (enabled by default) to support multiple concurrent
instances sharing the same database. However, if a Metals process crashes without
proper cleanup, it can leave a stale lock file that prevents proper AUTO_SERVER
coordination, causing new instances to fall back to in-memory database mode.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
log = logging.getLogger(__name__)
class MetalsDbStatus(Enum):
"""Status of the Metals H2 database for a project."""
NO_DATABASE = "no_database"
"""No .metals directory or database exists (fresh project)."""
NO_LOCK = "no_lock"
"""Database exists but no lock file (safe to start)."""
ACTIVE_INSTANCE = "active_instance"
"""Lock held by a running process (will share via AUTO_SERVER)."""
STALE_LOCK = "stale_lock"
"""Lock held by a dead process (needs cleanup)."""
@dataclass
class MetalsLockInfo:
"""Information extracted from an H2 database lock file."""
pid: int | None
"""Process ID that holds the lock, if parseable."""
port: int | None
"""TCP port for AUTO_SERVER connection, if parseable."""
lock_path: Path
"""Path to the lock file."""
is_stale: bool
"""True if the owning process is no longer running."""
raw_content: str
"""Raw content of the lock file for debugging."""
def parse_h2_lock_file(lock_path: Path) -> MetalsLockInfo | None:
"""
Parse an H2 database lock file to extract connection information.
The H2 lock file format varies by version but typically contains
server connection information. Common formats include:
- Text format: "server:localhost:9092" or similar
- Binary format with embedded PID
Args:
lock_path: Path to the .lock.db file
Returns:
MetalsLockInfo if the file can be parsed, None if file doesn't exist
or is completely unparsable.
"""
if not lock_path.exists():
return None
try:
# Try reading as text first (most common for H2 AUTO_SERVER)
content = lock_path.read_text(encoding="utf-8", errors="replace")
except OSError as e:
log.debug(f"Could not read lock file {lock_path}: {e}")
return None
pid: int | None = None
port: int | None = None
# Try to extract port from common H2 lock file formats
# Format 1: "server:localhost:PORT"
server_match = re.search(r"server:[\w.]+:(\d+)", content, re.IGNORECASE)
if server_match:
port = int(server_match.group(1))
# Format 2: Look for standalone port numbers (H2 uses ports in 9000+ range typically)
if port is None:
port_match = re.search(r"\b(9\d{3})\b", content)
if port_match:
port = int(port_match.group(1))
# Try to extract PID - H2 may embed this in various formats
pid_match = re.search(r"pid[=:]?\s*(\d+)", content, re.IGNORECASE)
if pid_match:
pid = int(pid_match.group(1))
# Check if the process is still alive
is_stale = False
if pid is not None:
is_stale = not is_metals_process_alive(pid)
elif port is not None:
# If we have a port but no PID, try to find a Metals process using that port
is_stale = not _is_port_in_use_by_metals(port)
else:
# Can't determine - assume stale if lock exists but we can't parse it
# and no Metals processes are running for this project
log.debug(f"Could not parse PID or port from lock file: {lock_path}")
is_stale = True # Conservative: treat unparsable as stale
return MetalsLockInfo(
pid=pid,
port=port,
lock_path=lock_path,
is_stale=is_stale,
raw_content=content[:200], # Truncate for logging
)
def is_metals_process_alive(pid: int) -> bool:
"""
Check if a process with the given PID is alive and is a Metals process.
Args:
pid: Process ID to check
Returns:
True if the process exists and appears to be a Metals LSP server.
"""
try:
import psutil
proc = psutil.Process(pid)
if not proc.is_running():
return False
# Check if this is actually a Metals process
cmdline = " ".join(proc.cmdline()).lower()
return _is_metals_cmdline(cmdline)
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
return False
except Exception as e:
log.debug(f"Error checking process {pid}: {e}")
return False
def _is_metals_cmdline(cmdline: str) -> bool:
"""Check if a command line string appears to be a Metals LSP server."""
cmdline_lower = cmdline.lower()
# Metals is a Scala/Java application
if "java" not in cmdline_lower:
return False
# Look for Metals-specific identifiers
return any(
marker in cmdline_lower
for marker in [
"metals",
"org.scalameta",
"-dmetals.client",
]
)
def _is_port_in_use_by_metals(port: int) -> bool:
"""Check if the given port is in use by a Metals process."""
try:
import psutil
for conn in psutil.net_connections(kind="tcp"):
if conn.laddr.port == port and conn.status == "LISTEN":
try:
proc = psutil.Process(conn.pid)
cmdline = " ".join(proc.cmdline()).lower()
if _is_metals_cmdline(cmdline):
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return False
except (psutil.AccessDenied, OSError) as e:
# On some systems, net_connections requires elevated privileges
log.debug(f"Could not check port {port}: {e}")
return False
def check_metals_db_status(project_path: Path) -> tuple[MetalsDbStatus, MetalsLockInfo | None]:
"""
Check the status of the Metals H2 database for a project.
This function determines whether it's safe to start a new Metals instance
and whether any cleanup is needed.
Args:
project_path: Path to the project root directory
Returns:
A tuple of (status, lock_info) where lock_info is populated for
ACTIVE_INSTANCE and STALE_LOCK statuses.
"""
metals_dir = project_path / ".metals"
db_path = metals_dir / "metals.mv.db"
lock_path = metals_dir / "metals.mv.db.lock.db"
if not metals_dir.exists():
log.debug(f"No .metals directory found at {metals_dir}")
return MetalsDbStatus.NO_DATABASE, None
if not db_path.exists():
log.debug(f"No Metals database found at {db_path}")
return MetalsDbStatus.NO_DATABASE, None
if not lock_path.exists():
log.debug(f"Metals database exists but no lock file at {lock_path}")
return MetalsDbStatus.NO_LOCK, None
# Lock file exists - parse it to determine status
lock_info = parse_h2_lock_file(lock_path)
if lock_info is None:
# Lock file exists but couldn't be read - treat as stale
log.warning(f"Could not read lock file at {lock_path}, treating as stale")
return MetalsDbStatus.STALE_LOCK, MetalsLockInfo(
pid=None,
port=None,
lock_path=lock_path,
is_stale=True,
raw_content="<unreadable>",
)
if lock_info.is_stale:
log.debug(f"Stale Metals lock detected: {lock_info}")
return MetalsDbStatus.STALE_LOCK, lock_info
else:
log.debug(f"Active Metals instance detected: {lock_info}")
return MetalsDbStatus.ACTIVE_INSTANCE, lock_info
def cleanup_stale_lock(lock_path: Path) -> bool:
"""
Remove a stale H2 database lock file.
This should only be called when we've verified the owning process is dead.
Removing a lock file from a running process could cause database corruption.
Args:
lock_path: Path to the .lock.db file to remove
Returns:
True if cleanup succeeded, False otherwise.
"""
if not lock_path.exists():
log.debug(f"Lock file already removed: {lock_path}")
return True
try:
lock_path.unlink()
log.info(f"Cleaned up stale Metals lock file: {lock_path}")
return True
except PermissionError as e:
log.warning(f"Permission denied removing stale lock file {lock_path}: {e}")
return False
except OSError as e:
log.warning(f"Could not remove stale lock file {lock_path}: {e}")
return False