mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 04:15:11 +00:00
feat(scala): Add stale lock detection for Metals H2 database (#1012)
* feat(scala): Add stale lock detection for Metals H2 database Add detection and automatic cleanup of stale Metals H2 database locks to improve multi-instance support. When a Metals process crashes without proper cleanup, it can leave a lock file that prevents proper AUTO_SERVER coordination with new instances. Changes: - Add metals_db_utils.py with detection logic for stale locks - Add StaleLockMode enum (auto-clean/warn/fail) for configurable behavior - Add MetalsStaleLockError exception for fail mode - Integrate stale lock checking into ScalaLanguageServer init - Add configuration options: on_stale_lock, log_multi_instance_notice - Add comprehensive unit tests for detection logic This enhancement allows Serena's Metals to coexist with VS Code or other editors running Metals on the same project, with proper handling of edge cases like crashed processes. * test(scala): Add integration tests for stale lock handling modes Add 7 integration tests for ScalaLanguageServer stale lock detection: - AUTO_CLEAN mode cleans stale locks and proceeds - WARN mode logs warning without cleanup - FAIL mode raises MetalsStaleLockError - ACTIVE_INSTANCE logs info when notice enabled - ACTIVE_INSTANCE silent when notice disabled - NO_DATABASE proceeds silently - NO_LOCK proceeds silently Tests use mocking to avoid requiring Java/Coursier/Metals. * docs(scala): Add multi-instance and stale lock documentation Document Metals multi-instance support via H2 AUTO_SERVER: - Explain how multiple Metals instances share database and Bloop - Document memory considerations (~500MB per JVM) - Add stale lock detection configuration options - Document on_stale_lock modes (auto-clean, warn, fail) - Add YAML configuration examples * docs: Clean up docs Removed memory considerations and additional configuration sections from the Scala setup guide. * refactor(scala): Extract settings helper and add module-level constants - Add DEFAULT_METALS_VERSION, DEFAULT_CLIENT_NAME, DEFAULT_ON_STALE_LOCK, DEFAULT_LOG_MULTI_INSTANCE_NOTICE constants to eliminate duplication - Extract _get_scala_settings() pure function for centralized settings parsing - Remove redundant cleanup_stale_lock import - Add warning log when AUTO_CLEAN mode fails to remove stale lock - Simplify lock path handling with ternary expression This refactoring improves code maintainability by centralizing Scala settings extraction and applying functional programming principles. * chore(scala): fix typo in docstring and comments
This commit is contained in:
1 parent
547a938832
commit
1ea5f0c25f
6 files changed
+1015
-5
No files matched your search
@@ -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)
|
||||
- Metals + sbt: [https://scalameta.org/metals/docs/build-tools/sbt](https://scalameta.org/metals/docs/build-tools/sbt)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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="<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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user