diff --git a/docs/03-special-guides/scala_setup_guide_for_serena.md b/docs/03-special-guides/scala_setup_guide_for_serena.md index 2c4b1cc8..1e4bebca 100644 --- a/docs/03-special-guides/scala_setup_guide_for_serena.md +++ b/docs/03-special-guides/scala_setup_guide_for_serena.md @@ -63,5 +63,41 @@ These instructions cover the setup for projects that use sbt as the build tool, Notes: - Ensure you completed the manual or auto‑import steps so that the build is compiled and indexed; otherwise, code navigation and references may be incomplete until the first successful compile. +--- +## Running Multiple Metals Instances + +Serena can run alongside other Metals instances (e.g., VS Code with Metals extension) on the same project. This is **fully supported** by Metals via H2 AUTO_SERVER mode. + +### How It Works + +Metals uses an H2 database (`.metals/metals.mv.db`) to cache semantic information. When multiple Metals instances run on the same project: + +- **H2 AUTO_SERVER**: The first instance becomes the TCP server; subsequent instances connect as clients +- **Bloop Build Server**: All instances share a single Bloop process (port 8212) +- **Compilation Results**: Shared via Bloop — no duplicate compilation + +### Stale Lock Detection + +If a Metals process crashes without proper cleanup, it may leave a stale lock file (`.metals/metals.mv.db.lock.db`). This can prevent proper AUTO_SERVER coordination, causing new instances to fall back to in-memory database mode (degraded experience). + +Serena automatically detects and handles stale locks based on your configuration: + +```yaml +# ~/.serena/serena_config.yml or .serena/project.yml +ls_specific_settings: + scala: + on_stale_lock: "auto-clean" # auto-clean | warn | fail + log_multi_instance_notice: true # Log info when another Metals detected +``` + +#### Stale Lock Modes + +| Mode | Behavior | +|------|----------| +| `auto-clean` | **(Default, Recommended)** Automatically removes stale lock files and proceeds normally. | +| `warn` | Logs a warning but proceeds. Metals may use in-memory database (slower). | +| `fail` | Raises an error and refuses to start. Useful for debugging lock issues. | + +--- ## Reference -- Metals + sbt: [https://scalameta.org/metals/docs/build-tools/sbt](https://scalameta.org/metals/docs/build-tools/sbt) \ No newline at end of file +- Metals + sbt: [https://scalameta.org/metals/docs/build-tools/sbt](https://scalameta.org/metals/docs/build-tools/sbt) diff --git a/src/solidlsp/language_servers/scala_language_server.py b/src/solidlsp/language_servers/scala_language_server.py index e835c36f..ee500db7 100644 --- a/src/solidlsp/language_servers/scala_language_server.py +++ b/src/solidlsp/language_servers/scala_language_server.py @@ -7,6 +7,7 @@ import os import pathlib import shutil import subprocess +from enum import Enum from overrides import override @@ -23,16 +24,99 @@ if not PlatformUtils.get_platform_id().value.startswith("win"): log = logging.getLogger(__name__) +# Default configuration constants +DEFAULT_METALS_VERSION = "1.6.4" +DEFAULT_CLIENT_NAME = "Serena" +DEFAULT_ON_STALE_LOCK = "auto-clean" +DEFAULT_LOG_MULTI_INSTANCE_NOTICE = True + + +class StaleLockMode(Enum): + """Mode for handling stale Metals H2 database locks.""" + + AUTO_CLEAN = "auto-clean" + """Automatically remove stale lock files (default, recommended).""" + + WARN = "warn" + """Log a warning but proceed; may result in degraded experience.""" + + FAIL = "fail" + """Raise an error and refuse to start.""" + + +def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object]: + """ + Extract Scala-specific settings with defaults applied. + + Returns a dictionary with keys: + - metals_version: str + - client_name: str + - on_stale_lock: StaleLockMode + - log_multi_instance_notice: bool + """ + from solidlsp.ls_config import Language + + defaults: dict[str, object] = { + "metals_version": DEFAULT_METALS_VERSION, + "client_name": DEFAULT_CLIENT_NAME, + "on_stale_lock": StaleLockMode.AUTO_CLEAN, + "log_multi_instance_notice": DEFAULT_LOG_MULTI_INSTANCE_NOTICE, + } + + if not solidlsp_settings.ls_specific_settings: + return defaults + + scala_settings = solidlsp_settings.get_ls_specific_settings(Language.SCALA) + + # Parse stale lock mode with validation + on_stale_lock_str = scala_settings.get("on_stale_lock", DEFAULT_ON_STALE_LOCK) + try: + on_stale_lock = StaleLockMode(on_stale_lock_str) + except ValueError: + log.warning(f"Invalid on_stale_lock value '{on_stale_lock_str}', using '{DEFAULT_ON_STALE_LOCK}'") + on_stale_lock = StaleLockMode.AUTO_CLEAN + + return { + "metals_version": scala_settings.get("metals_version", DEFAULT_METALS_VERSION), + "client_name": scala_settings.get("client_name", DEFAULT_CLIENT_NAME), + "on_stale_lock": on_stale_lock, + "log_multi_instance_notice": scala_settings.get("log_multi_instance_notice", DEFAULT_LOG_MULTI_INSTANCE_NOTICE), + } + class ScalaLanguageServer(SolidLanguageServer): """ - Provides Scala specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Scala. + Provides Scala specific instantiation of the LanguageServer class. + Contains various configurations and settings specific to Scala. + + Configurable options in ls_specific_settings (in serena_config.yml): + + ls_specific_settings: + scala: + # Stale lock handling: auto-clean | warn | fail + on_stale_lock: 'auto-clean' + # Log notice when another Metals instance is detected + log_multi_instance_notice: true + # Metals version to bootstrap (default: DEFAULT_METALS_VERSION) + metals_version: '1.6.4' + # Client identifier sent to Metals (default: DEFAULT_CLIENT_NAME) + client_name: 'Serena' + + Multi-instance support: + Metals uses H2 AUTO_SERVER mode (enabled by default) to support multiple + concurrent instances sharing the same database. Running Serena's Metals + alongside VS Code's Metals is designed to work. The only issue is stale + locks from crashed processes, which this class can detect and clean up. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ - Creates a ScalaLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + Creates a ScalaLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. """ + # Check for stale locks before setting up dependencies (fail-fast) + self._check_metals_db_status(repository_root_path, solidlsp_settings) + scala_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, @@ -42,6 +126,62 @@ class ScalaLanguageServer(SolidLanguageServer): solidlsp_settings, ) + def _check_metals_db_status(self, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: + """ + Check the Metals H2 database status and handle stale locks. + + This method is called before setting up runtime dependencies to fail-fast + if there's a stale lock that the user has configured to fail on. + """ + from pathlib import Path + + from solidlsp.ls_exceptions import MetalsStaleLockError + from solidlsp.util.metals_db_utils import ( + MetalsDbStatus, + check_metals_db_status, + cleanup_stale_lock, + ) + + project_path = Path(repository_root_path) + status, lock_info = check_metals_db_status(project_path) + + # Get settings using the shared helper function + settings = _get_scala_settings(solidlsp_settings) + on_stale_lock: StaleLockMode = settings["on_stale_lock"] # type: ignore[assignment] + log_multi_instance_notice: bool = settings["log_multi_instance_notice"] # type: ignore[assignment] + + if status == MetalsDbStatus.ACTIVE_INSTANCE: + if log_multi_instance_notice and lock_info: + log.info( + f"Another Metals instance detected (PID: {lock_info.pid}). " + "This is fine - Metals supports multiple instances via H2 AUTO_SERVER. " + "Both instances will share the database and Bloop build server." + ) + + elif status == MetalsDbStatus.STALE_LOCK: + lock_path = lock_info.lock_path if lock_info else project_path / ".metals" / "metals.mv.db.lock.db" + lock_path_str = str(lock_path) + + if on_stale_lock == StaleLockMode.AUTO_CLEAN: + log.info(f"Stale Metals lock detected, cleaning up: {lock_path_str}") + cleanup_success = cleanup_stale_lock(lock_path) + if not cleanup_success: + log.warning( + f"Failed to clean up stale lock at {lock_path_str}. " + "Metals may fall back to in-memory database (degraded experience)." + ) + + elif on_stale_lock == StaleLockMode.WARN: + log.warning( + f"Stale Metals lock detected at {lock_path_str}. " + "A previous Metals process may have crashed. " + "Metals will fall back to in-memory database (degraded experience). " + "Consider removing the lock file manually or setting on_stale_lock='auto-clean'." + ) + + elif on_stale_lock == StaleLockMode.FAIL: + raise MetalsStaleLockError(lock_path_str) + @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [ @@ -57,7 +197,10 @@ class ScalaLanguageServer(SolidLanguageServer): """ assert shutil.which("java") is not None, "JDK is not installed or not in PATH." - metals_version = "1.6.4" + # Get settings using the shared helper function + settings = _get_scala_settings(solidlsp_settings) + metals_version: str = settings["metals_version"] # type: ignore[assignment] + client_name: str = settings["client_name"] # type: ignore[assignment] metals_home = os.path.join(cls.ls_resources_dir(solidlsp_settings), "metals-lsp") os.makedirs(metals_home, exist_ok=True) @@ -98,7 +241,7 @@ class ScalaLanguageServer(SolidLanguageServer): "--java-opt", "-Xms100m", "--java-opt", - "-Dmetals.client=Serena", + f"-Dmetals.client={client_name}", artifact, "-o", metals_executable, diff --git a/src/solidlsp/ls_exceptions.py b/src/solidlsp/ls_exceptions.py index 481d188e..1a82f6b6 100644 --- a/src/solidlsp/ls_exceptions.py +++ b/src/solidlsp/ls_exceptions.py @@ -50,3 +50,25 @@ class SolidLSPException(Exception): s += " " s += f"(caused by {self.cause})" return s + + +class MetalsStaleLockError(SolidLSPException): + """ + Raised when a stale Metals H2 database lock is detected and the user + has configured fail-on-stale-lock behavior. + + A stale lock occurs when a previous Metals process crashed without + cleaning up its lock file, which can prevent proper AUTO_SERVER + coordination with new instances. + """ + + def __init__(self, lock_path: str, message: str | None = None) -> None: + self.lock_path = lock_path + if message is None: + message = ( + f"Stale Metals lock file detected at {lock_path}. " + "A previous Metals process may have crashed. " + "To resolve: remove the lock file manually, or set " + "on_stale_lock='auto-clean' in ls_specific_settings.scala." + ) + super().__init__(message) diff --git a/src/solidlsp/util/metals_db_utils.py b/src/solidlsp/util/metals_db_utils.py new file mode 100644 index 00000000..9e91eebf --- /dev/null +++ b/src/solidlsp/util/metals_db_utils.py @@ -0,0 +1,280 @@ +""" +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="", + ) + + 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 diff --git a/test/solidlsp/scala/test_metals_db_utils.py b/test/solidlsp/scala/test_metals_db_utils.py new file mode 100644 index 00000000..5a31af24 --- /dev/null +++ b/test/solidlsp/scala/test_metals_db_utils.py @@ -0,0 +1,268 @@ +""" +Unit tests for the metals_db_utils module. + +Tests the detection of Metals H2 database status and stale lock handling. +""" + +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from solidlsp.util.metals_db_utils import ( + MetalsDbStatus, + check_metals_db_status, + cleanup_stale_lock, + is_metals_process_alive, + parse_h2_lock_file, +) + + +@pytest.mark.scala +class TestParseH2LockFile: + """Tests for parse_h2_lock_file function.""" + + def test_returns_none_when_file_does_not_exist(self, tmp_path: Path) -> None: + """Should return None when lock file doesn't exist.""" + lock_path = tmp_path / "nonexistent.lock.db" + result = parse_h2_lock_file(lock_path) + assert result is None + + def test_parses_server_format_lock_file(self, tmp_path: Path) -> None: + """Should parse lock file with server:host:port format.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.write_text("server:localhost:9092\n") + + result = parse_h2_lock_file(lock_path) + + assert result is not None + assert result.port == 9092 + assert result.lock_path == lock_path + + def test_parses_port_only_format(self, tmp_path: Path) -> None: + """Should extract port from content containing a port number.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.write_text("some content 9123 more content\n") + + result = parse_h2_lock_file(lock_path) + + assert result is not None + assert result.port == 9123 + + def test_parses_pid_format(self, tmp_path: Path) -> None: + """Should extract PID from lock file content.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.write_text("pid=12345\nserver:localhost:9092\n") + + result = parse_h2_lock_file(lock_path) + + assert result is not None + assert result.pid == 12345 + assert result.port == 9092 + + def test_handles_unreadable_file(self, tmp_path: Path) -> None: + """Should return None for unreadable files.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.write_text("content") + # Make file unreadable (Unix only) + if os.name != "nt": + lock_path.chmod(0o000) + try: + result = parse_h2_lock_file(lock_path) + assert result is None + finally: + lock_path.chmod(0o644) + + def test_truncates_raw_content(self, tmp_path: Path) -> None: + """Should truncate raw_content to 200 chars.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + long_content = "x" * 500 + lock_path.write_text(long_content) + + result = parse_h2_lock_file(lock_path) + + assert result is not None + assert len(result.raw_content) == 200 + + +@pytest.mark.scala +class TestIsMetalsProcessAlive: + """Tests for is_metals_process_alive function.""" + + def test_returns_false_for_nonexistent_process(self) -> None: + """Should return False for a PID that doesn't exist.""" + # Use a very high PID that's unlikely to exist + result = is_metals_process_alive(999999999) + assert result is False + + def test_returns_true_for_metals_process(self) -> None: + """Should return True for a running Metals process.""" + import psutil + + with patch.object(psutil, "Process") as mock_process_class: + mock_proc = MagicMock() + mock_proc.is_running.return_value = True + mock_proc.cmdline.return_value = [ + "java", + "-Dmetals.client=vscode", + "-jar", + "metals.jar", + ] + mock_process_class.return_value = mock_proc + + result = is_metals_process_alive(12345) + + assert result is True + + def test_returns_false_for_non_metals_java_process(self) -> None: + """Should return False for a Java process that isn't Metals.""" + import psutil + + with patch.object(psutil, "Process") as mock_process_class: + mock_proc = MagicMock() + mock_proc.is_running.return_value = True + mock_proc.cmdline.return_value = [ + "java", + "-jar", + "some-other-app.jar", + ] + mock_process_class.return_value = mock_proc + + result = is_metals_process_alive(12345) + + assert result is False + + def test_returns_false_for_non_running_process(self) -> None: + """Should return False for a process that's not running.""" + import psutil + + with patch.object(psutil, "Process") as mock_process_class: + mock_proc = MagicMock() + mock_proc.is_running.return_value = False + mock_process_class.return_value = mock_proc + + result = is_metals_process_alive(12345) + + assert result is False + + def test_handles_no_such_process(self) -> None: + """Should return False when process doesn't exist.""" + import psutil + + with patch.object(psutil, "Process") as mock_process_class: + mock_process_class.side_effect = psutil.NoSuchProcess(12345) + + result = is_metals_process_alive(12345) + + assert result is False + + +@pytest.mark.scala +class TestCheckMetalsDbStatus: + """Tests for check_metals_db_status function.""" + + def test_returns_no_database_when_metals_dir_missing(self, tmp_path: Path) -> None: + """Should return NO_DATABASE when .metals directory doesn't exist.""" + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.NO_DATABASE + assert lock_info is None + + def test_returns_no_database_when_db_missing(self, tmp_path: Path) -> None: + """Should return NO_DATABASE when database file doesn't exist.""" + metals_dir = tmp_path / ".metals" + metals_dir.mkdir() + + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.NO_DATABASE + assert lock_info is None + + def test_returns_no_lock_when_lock_file_missing(self, tmp_path: Path) -> None: + """Should return NO_LOCK when database exists but lock doesn't.""" + metals_dir = tmp_path / ".metals" + metals_dir.mkdir() + db_path = metals_dir / "metals.mv.db" + db_path.touch() + + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.NO_LOCK + assert lock_info is None + + def test_returns_active_instance_when_process_alive(self, tmp_path: Path) -> None: + """Should return ACTIVE_INSTANCE when lock holder is running.""" + import solidlsp.util.metals_db_utils as metals_utils + + metals_dir = tmp_path / ".metals" + metals_dir.mkdir() + db_path = metals_dir / "metals.mv.db" + db_path.touch() + lock_path = metals_dir / "metals.mv.db.lock.db" + lock_path.write_text("pid=12345\nserver:localhost:9092\n") + + with patch.object(metals_utils, "is_metals_process_alive", return_value=True): + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.ACTIVE_INSTANCE + assert lock_info is not None + assert lock_info.is_stale is False + + def test_returns_stale_lock_when_process_dead(self, tmp_path: Path) -> None: + """Should return STALE_LOCK when lock holder is not running.""" + import solidlsp.util.metals_db_utils as metals_utils + + metals_dir = tmp_path / ".metals" + metals_dir.mkdir() + db_path = metals_dir / "metals.mv.db" + db_path.touch() + lock_path = metals_dir / "metals.mv.db.lock.db" + lock_path.write_text("pid=12345\nserver:localhost:9092\n") + + with patch.object(metals_utils, "is_metals_process_alive", return_value=False): + status, lock_info = check_metals_db_status(tmp_path) + + assert status == MetalsDbStatus.STALE_LOCK + assert lock_info is not None + assert lock_info.is_stale is True + + +@pytest.mark.scala +class TestCleanupStaleLock: + """Tests for cleanup_stale_lock function.""" + + def test_removes_lock_file(self, tmp_path: Path) -> None: + """Should successfully remove a lock file.""" + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.touch() + + result = cleanup_stale_lock(lock_path) + + assert result is True + assert not lock_path.exists() + + def test_returns_true_when_file_already_removed(self, tmp_path: Path) -> None: + """Should return True when file doesn't exist.""" + lock_path = tmp_path / "nonexistent.lock.db" + + result = cleanup_stale_lock(lock_path) + + assert result is True + + def test_returns_false_on_permission_error(self, tmp_path: Path) -> None: + """Should return False when file can't be removed due to permissions.""" + if os.name == "nt": + pytest.skip("Permission test not reliable on Windows") + + lock_path = tmp_path / "metals.mv.db.lock.db" + lock_path.touch() + # Make parent directory read-only + tmp_path.chmod(0o555) + + try: + result = cleanup_stale_lock(lock_path) + assert result is False + assert lock_path.exists() + finally: + tmp_path.chmod(0o755) diff --git a/test/solidlsp/scala/test_scala_stale_lock_handling.py b/test/solidlsp/scala/test_scala_stale_lock_handling.py new file mode 100644 index 00000000..d2819c02 --- /dev/null +++ b/test/solidlsp/scala/test_scala_stale_lock_handling.py @@ -0,0 +1,261 @@ +""" +Tests for ScalaLanguageServer stale lock detection and handling modes. + +These tests verify the ScalaLanguageServer's behavior when detecting stale Metals locks. +They use mocking to avoid requiring an actual Scala project or Metals server. +""" + +import logging +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from _pytest.logging import LogCaptureFixture + +from solidlsp.language_servers.scala_language_server import ScalaLanguageServer +from solidlsp.ls_config import Language +from solidlsp.settings import SolidLSPSettings +from solidlsp.util.metals_db_utils import MetalsDbStatus, MetalsLockInfo + +pytestmark = pytest.mark.scala + + +class TestStaleLockHandling: + """Tests for ScalaLanguageServer stale lock detection and handling modes.""" + + @pytest.fixture + def sample_lock_info(self, tmp_path: Path) -> MetalsLockInfo: + """Create a sample MetalsLockInfo for testing.""" + lock_path = tmp_path / ".metals" / "metals.mv.db.lock.db" + return MetalsLockInfo( + pid=12345, + port=9092, + lock_path=lock_path, + is_stale=True, + raw_content="SERVER:localhost:9092:12345", + ) + + @pytest.fixture + def mock_setup_dependencies(self) -> Any: + """Mock _setup_runtime_dependencies to avoid needing Java/Coursier.""" + return patch.object( + ScalaLanguageServer, + "_setup_runtime_dependencies", + return_value=["/fake/metals"], + ) + + def test_auto_clean_mode_cleans_stale_lock( + self, + tmp_path: Path, + sample_lock_info: MetalsLockInfo, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test AUTO_CLEAN mode removes stale lock and proceeds.""" + cleanup_mock = MagicMock(return_value=True) + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.STALE_LOCK, sample_lock_info), + ), + patch( + "solidlsp.util.metals_db_utils.cleanup_stale_lock", + cleanup_mock, + ), + mock_setup_dependencies, + patch.object(ScalaLanguageServer, "__init__", lambda self, *args, **kwargs: None), + ): + # Create instance without calling __init__ + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "auto-clean"}}) + + # Call the method under test + ls._check_metals_db_status(str(tmp_path), settings) + + # Verify cleanup was called + cleanup_mock.assert_called_once_with(sample_lock_info.lock_path) + + def test_warn_mode_logs_warning_without_cleanup( + self, + tmp_path: Path, + sample_lock_info: MetalsLockInfo, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test WARN mode logs warning but does not clean up.""" + cleanup_mock = MagicMock(return_value=True) + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.STALE_LOCK, sample_lock_info), + ), + patch( + "solidlsp.util.metals_db_utils.cleanup_stale_lock", + cleanup_mock, + ), + mock_setup_dependencies, + caplog.at_level(logging.WARNING), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "warn"}}) + + ls._check_metals_db_status(str(tmp_path), settings) + + # Verify cleanup was NOT called + cleanup_mock.assert_not_called() + + # Verify warning was logged + assert any("Stale Metals lock detected" in record.message for record in caplog.records) + + def test_fail_mode_raises_exception( + self, + tmp_path: Path, + sample_lock_info: MetalsLockInfo, + mock_setup_dependencies: Any, + ) -> None: + """Test FAIL mode raises MetalsStaleLockError.""" + from solidlsp.ls_exceptions import MetalsStaleLockError + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.STALE_LOCK, sample_lock_info), + ), + mock_setup_dependencies, + pytest.raises(MetalsStaleLockError) as exc_info, + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "fail"}}) + + ls._check_metals_db_status(str(tmp_path), settings) + + assert str(sample_lock_info.lock_path) in str(exc_info.value) + + def test_active_instance_logs_info_when_enabled( + self, + tmp_path: Path, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test ACTIVE_INSTANCE logs info message when log_multi_instance_notice is true.""" + active_lock_info = MetalsLockInfo( + pid=99999, + port=9092, + lock_path=tmp_path / ".metals" / "metals.mv.db.lock.db", + is_stale=False, + raw_content="SERVER:localhost:9092:99999", + ) + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.ACTIVE_INSTANCE, active_lock_info), + ), + mock_setup_dependencies, + caplog.at_level(logging.INFO), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings( + ls_specific_settings={ + Language.SCALA: { + "on_stale_lock": "auto-clean", + "log_multi_instance_notice": True, + } + } + ) + + ls._check_metals_db_status(str(tmp_path), settings) + + # Verify info about multi-instance was logged + assert any("Another Metals instance detected" in record.message for record in caplog.records) + + def test_active_instance_silent_when_notice_disabled( + self, + tmp_path: Path, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test ACTIVE_INSTANCE does not log when log_multi_instance_notice is false.""" + active_lock_info = MetalsLockInfo( + pid=99999, + port=9092, + lock_path=tmp_path / ".metals" / "metals.mv.db.lock.db", + is_stale=False, + raw_content="SERVER:localhost:9092:99999", + ) + + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.ACTIVE_INSTANCE, active_lock_info), + ), + mock_setup_dependencies, + caplog.at_level(logging.INFO), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings( + ls_specific_settings={ + Language.SCALA: { + "on_stale_lock": "auto-clean", + "log_multi_instance_notice": False, + } + } + ) + + ls._check_metals_db_status(str(tmp_path), settings) + + # Verify no multi-instance message was logged + assert not any("Another Metals instance detected" in record.message for record in caplog.records) + + def test_no_database_proceeds_silently( + self, + tmp_path: Path, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test NO_DATABASE status proceeds without any special handling.""" + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.NO_DATABASE, None), + ), + mock_setup_dependencies, + caplog.at_level(logging.DEBUG), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "auto-clean"}}) + + # Should complete without error + ls._check_metals_db_status(str(tmp_path), settings) + + # No stale lock or multi-instance messages + assert not any("Stale" in record.message for record in caplog.records) + assert not any("Another Metals instance" in record.message for record in caplog.records) + + def test_no_lock_proceeds_silently( + self, + tmp_path: Path, + mock_setup_dependencies: Any, + caplog: LogCaptureFixture, + ) -> None: + """Test NO_LOCK status proceeds without any special handling.""" + with ( + patch( + "solidlsp.util.metals_db_utils.check_metals_db_status", + return_value=(MetalsDbStatus.NO_LOCK, None), + ), + mock_setup_dependencies, + caplog.at_level(logging.DEBUG), + ): + ls = object.__new__(ScalaLanguageServer) + settings = SolidLSPSettings(ls_specific_settings={Language.SCALA: {"on_stale_lock": "auto-clean"}}) + + # Should complete without error + ls._check_metals_db_status(str(tmp_path), settings) + + # No stale lock or multi-instance messages + assert not any("Stale" in record.message for record in caplog.records) + assert not any("Another Metals instance" in record.message for record in caplog.records)