mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 12:17:02 +00:00
Merge branch 'main' into release-1.7
This commit is contained in:
commit
140d0b03ee
24 files changed
+994
-109
No files matched your search
@@ -27,9 +27,11 @@
|
||||
#
|
||||
# CACHING
|
||||
# Per-batch language-server cache keyed by os+batch (language-servers-<os>-<batch>),
|
||||
# plus dedicated caches for the uv venv (keyed on uv.lock), swiftly, ZLS, FPC and
|
||||
# the Go build/bin. Most niche toolchains restore from cache, so that batch is fast
|
||||
# despite its size.
|
||||
# plus dedicated caches for the uv venv (keyed on uv.lock), swiftly, ZLS, FPC, the
|
||||
# Go build/bin, and npm for the web fixtures (Angular node_modules keyed on its
|
||||
# lockfile; the npm download cache via setup-node, which also warms the svelte
|
||||
# fixture's test-time `npm ci`). Most niche toolchains restore from cache, so that
|
||||
# batch is fast despite its size.
|
||||
#
|
||||
# TESTING / SKIPS
|
||||
# All language skip logic is centralized in test/conftest.py
|
||||
@@ -110,6 +112,13 @@ jobs:
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.x'
|
||||
# npm download cache, catch-all only (empty string disables caching in other
|
||||
# batches -- they run no npm installs). Warms the svelte fixture's test-time
|
||||
# `npm ci` and the global/LS npm installs; keyed on the committed lockfiles.
|
||||
cache: ${{ matrix.batch == 'catch-all' && 'npm' || '' }}
|
||||
cache-dependency-path: |
|
||||
test/resources/repos/angular/test_repo/package-lock.json
|
||||
test/resources/repos/svelte/test_repo/package-lock.json
|
||||
- name: Ensure cached directory exist before calling cache-related actions
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -719,8 +728,17 @@ jobs:
|
||||
echo "PERL_MB_OPT=--install_base \"$HOME/perl5\"" >> $GITHUB_ENV
|
||||
echo "PERL_MM_OPT=INSTALL_BASE=$HOME/perl5" >> $GITHUB_ENV
|
||||
echo "$HOME/perl5/bin" >> $GITHUB_PATH
|
||||
- name: Install npm deps for Angular test repo
|
||||
# The Angular fixture's node_modules is cached directly (exact lockfile match, like
|
||||
# the uv venv cache) so the npm install below is skipped entirely on a hit.
|
||||
- name: Cache Angular test repo node_modules
|
||||
if: matrix.batch == 'catch-all'
|
||||
id: cache-angular-npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: test/resources/repos/angular/test_repo/node_modules
|
||||
key: angular-npm-${{ runner.os }}-lock-${{ hashFiles('test/resources/repos/angular/test_repo/package-lock.json') }}
|
||||
- name: Install npm deps for Angular test repo
|
||||
if: matrix.batch == 'catch-all' && steps.cache-angular-npm.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -d test/resources/repos/angular/test_repo ]; then
|
||||
|
||||
+21
-3
@@ -4,6 +4,14 @@ Status of the `main` branch. Changes prior to the next official version change w
|
||||
|
||||
* General:
|
||||
- Fix: Race conditions in ProjectServer when used by multiple clients in parallel
|
||||
- Fix: `GitignoreParser` interpolated a directory's name unescaped into gitignore pattern position;
|
||||
a directory named with pattern metacharacters (e.g. a stray `***`) could turn a scoped pattern
|
||||
into one matching far more than intended, silently excluding most or all of the project from
|
||||
indexing #1806
|
||||
- Fix: cleanup of an independently-started LSP process and its children required enumerating the
|
||||
system process table (`psutil`), which can be denied even for processes Serena owns in a
|
||||
sandboxed environment; cleanup now signals the known process group directly (#1818)
|
||||
- Fix: the README, the Language Support docs page and the project template omitted several already-supported language servers
|
||||
- Fix: a tool call exceeding the timeout blocked the task executor indefinitely; the executor now
|
||||
recovers without user-induced cancellation
|
||||
- Add Grok Build support (context `grok`, setup CLI, hooks)
|
||||
@@ -27,9 +35,11 @@ Status of the `main` branch. Changes prior to the next official version change w
|
||||
* CLI:
|
||||
- Fix: `start-mcp-server` help text for `--project-from-cwd` falsely promised a fallback to the CWD, which was
|
||||
removed in v1.0.0 #1773
|
||||
- Fix: `project health-check` always exited with code 0, even when the check failed, so callers
|
||||
(CI, scripts) could not act on its verdict; it now exits with code 1 on failure. A `find_symbol`
|
||||
result without any matches is now reported as a failure rather than as a warning.
|
||||
- Improve `project health-check`:
|
||||
- Fix: Process always exited with code 0, even when the check failed, so callers
|
||||
(CI, scripts) could not act on its verdict; it now exits with code 1 on failure. A `find_symbol`
|
||||
result without any matches is now reported as a failure rather than as a warning.
|
||||
- Disable symbol groupers, remove flawed pattern search test
|
||||
|
||||
* Tools:
|
||||
- `find_symbol`, `jet_brains_find_symbol`: Change tool description to improve tool search results in clients that load tools dynamically
|
||||
@@ -55,6 +65,10 @@ Status of the `main` branch. Changes prior to the next official version change w
|
||||
`intellicode_version`, `intellicode_xmx` and `intellicode_xms` settings remain accepted but are ignored #1821
|
||||
- Kotlin: update the managed Kotlin LSP from `261.13587.0` to `262.9593.0`, including support for the
|
||||
new platform-specific archive layout and Windows ARM64 builds
|
||||
- Add support for Wolfram Language via the official [WolframResearch LSPServer](https://github.com/WolframResearch/LSPServer) paclet.
|
||||
Requires Wolfram Mathematica 13.0+ or Wolfram Engine 12.1+. Set `WOLFRAM_PATH` environment variable or configure
|
||||
`ls_path` in `ls_specific_settings`. Supports .wl and .wls files with diagnostics, document symbols,
|
||||
within-file references, hover documentation, and formatting.
|
||||
- Nix/nixd: support custom `ls_path` launchers and external JSON settings through `config_path` #1737
|
||||
- Fix: Nix/nixd diagnostics now use published diagnostics instead of the unsupported
|
||||
`textDocument/diagnostic` request, which terminated nixd #1802
|
||||
@@ -86,6 +100,10 @@ Status of the `main` branch. Changes prior to the next official version change w
|
||||
`Deno.*` globals, which the plain TypeScript language server does not. Overlaps TypeScript on
|
||||
file extensions, so it is not auto-detected and must be selected explicitly; requires the
|
||||
`deno` CLI on PATH
|
||||
- Fix: `find_referencing_symbols` reported file-level containers for references located inside Go
|
||||
struct bodies, interface bodies and `const` groups, because containing-symbol detection admitted
|
||||
only Python's container kinds; `Struct`, `Interface` and `Constant` symbols are now admitted as
|
||||
containers
|
||||
|
||||
* JetBrains:
|
||||
- `jet_brains_find_symbol`: Disallow wildcard-only search, delegating to overview tool if request is for file
|
||||
|
||||
@@ -110,7 +110,7 @@ Serena incorporates a powerful abstraction layer for the integration of language
|
||||
The underlying language servers are typically open-source projects or at least freely available for use.
|
||||
|
||||
When using Serena's language server backend, we provide **support for over 40 programming languages**, including
|
||||
Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, CUE, Dart, Deno, Elixir, Elm, Erlang, Fortran, F#, GDScript, Gleam, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, LaTeX, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nextflow, Nix, OCaml, Pascal, Perl, PHP, PowerShell, Python, QML, R, Rego, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, SystemVerilog, Terraform, TOML, TypeScript, Vue, WGSL, YAML, and Zig.
|
||||
Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, CUE, Dart, Deno, Elixir, Elm, Erlang, Fortran, F#, GDScript, Gleam, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, LaTeX, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nextflow, Nix, OCaml, Pascal, Perl, PHP, PowerShell, Python, QML, R, Rego, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, SystemVerilog, Terraform, TOML, TypeScript, Vue, WGSL, Wolfram Language, YAML, and Zig.
|
||||
|
||||
### The Serena JetBrains Plugin
|
||||
|
||||
|
||||
@@ -177,8 +177,10 @@ Some languages require additional installations or setup steps, as noted.
|
||||
globals, which the plain TypeScript language server does not; overlaps `typescript` on file extensions,
|
||||
so it is not auto-detected and must be set as the language explicitly — do not also enable `typescript`
|
||||
for the same files)
|
||||
* **Vue**
|
||||
* **Vue**
|
||||
(3.x with TypeScript; requires Node.js v18+ and npm; supports .vue Single File Components with monorepo detection)
|
||||
* **Wolfram Language**
|
||||
(requires Wolfram Mathematica 13.0+ or Wolfram Engine 12.1+; uses the official [WolframResearch LSPServer](https://github.com/WolframResearch/LSPServer) paclet; supports .wl and .wls files; references are within-file only)
|
||||
* **YAML**
|
||||
* **JSON**
|
||||
(experimental; must be explicitly added to the languages list; requires Node.js and npm)
|
||||
|
||||
@@ -381,6 +381,7 @@ markers = [
|
||||
"ada: language server running for Ada / SPARK (uses AdaCore Ada Language Server)",
|
||||
"qml: language server running for QML (uses Qt's qmlls)",
|
||||
"gleam: language server running for Gleam (uses the Gleam compiler's bundled `gleam lsp`)",
|
||||
"wolfram: language server running for Wolfram Language (requires Mathematica 13.0+ or Wolfram Engine 12.1+)",
|
||||
]
|
||||
|
||||
[tool.codespell]
|
||||
|
||||
+10
-26
@@ -921,14 +921,14 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
@click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd())
|
||||
def health_check(project: str) -> None:
|
||||
"""
|
||||
Perform a comprehensive health check of the project's tools and language server.
|
||||
Perform a basic health check which checks whether language server tools are functional for the project.
|
||||
|
||||
:param project: path to the project directory, defaults to the current working directory.
|
||||
"""
|
||||
# NOTE: completely written by Claude Code, only functionality was reviewed, not implementation
|
||||
from serena.agent import SerenaAgent
|
||||
from serena.project import Project
|
||||
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool
|
||||
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool
|
||||
|
||||
logging.configure(level=logging.INFO)
|
||||
project_path = os.path.abspath(project)
|
||||
@@ -977,7 +977,6 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
overview_tool = agent.get_tool(GetSymbolsOverviewTool)
|
||||
find_symbol_tool = agent.get_tool(FindSymbolTool)
|
||||
find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
|
||||
search_pattern_tool = agent.get_tool(SearchForPatternTool)
|
||||
|
||||
# Test 1: Get symbols overview
|
||||
log.info("Testing GetSymbolsOverviewTool on file: %s", target_file)
|
||||
@@ -1006,42 +1005,27 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
|
||||
# Test 2: FindSymbolTool
|
||||
log.info("Testing FindSymbolTool for symbol: %s", symbol_name)
|
||||
find_symbol_result = agent.execute_task(
|
||||
lambda: find_symbol_tool.apply(symbol_name, relative_path=target_file, include_body=True)
|
||||
)
|
||||
with find_symbol_tool.symbol_dict_grouper.disabled_context():
|
||||
find_symbol_result = agent.execute_task(
|
||||
lambda: find_symbol_tool.apply(symbol_name, relative_path=target_file, include_body=True)
|
||||
)
|
||||
find_symbol_data = json.loads(find_symbol_result)
|
||||
log.info("FindSymbolTool found %d matches for symbol %s", len(find_symbol_data), symbol_name)
|
||||
|
||||
# Test 3: FindReferencingSymbolsTool
|
||||
log.info("Testing FindReferencingSymbolsTool for symbol: %s", symbol_name)
|
||||
try:
|
||||
find_refs_result = agent.execute_task(lambda: find_refs_tool.apply(symbol_name, relative_path=target_file))
|
||||
find_refs_data = json.loads(find_refs_result)
|
||||
log.info("FindReferencingSymbolsTool found %d references for symbol %s", len(find_refs_data), symbol_name)
|
||||
with find_refs_tool.symbol_dict_grouper.disabled_context():
|
||||
find_refs_result = agent.execute_task(lambda: find_refs_tool.apply(symbol_name, relative_path=target_file))
|
||||
find_refs_data = json.loads(find_refs_result)
|
||||
log.info("FindReferencingSymbolsTool found %d references for symbol %s", len(find_refs_data), symbol_name)
|
||||
except Exception as e:
|
||||
log.warning("FindReferencingSymbolsTool failed for symbol %s: %s", symbol_name, str(e))
|
||||
find_refs_data = []
|
||||
|
||||
# Test 4: SearchForPatternTool to verify references
|
||||
log.info("Testing SearchForPatternTool for pattern: %s", symbol_name)
|
||||
try:
|
||||
search_result = agent.execute_task(
|
||||
lambda: search_pattern_tool.apply(substring_pattern=symbol_name, restrict_search_to_code_files=True)
|
||||
)
|
||||
search_data = json.loads(search_result)
|
||||
pattern_matches = sum(len(matches) for matches in search_data.values())
|
||||
log.info("SearchForPatternTool found %d pattern matches for %s", pattern_matches, symbol_name)
|
||||
except Exception as e:
|
||||
log.warning("SearchForPatternTool failed for pattern %s: %s", symbol_name, str(e))
|
||||
pattern_matches = 0
|
||||
|
||||
# Verify tools worked as expected
|
||||
if not find_symbol_data:
|
||||
raise ProjectCommands._HealthCheckFailure("FindSymbolTool returned no results")
|
||||
|
||||
if len(find_refs_data) == 0 and pattern_matches == 0:
|
||||
log.warning("Both FindReferencingSymbolsTool and SearchForPatternTool found no matches - this might indicate an issue")
|
||||
|
||||
log.info("Health check completed successfully")
|
||||
|
||||
# expected failures and unexpected exceptions are reported alike: both mean the project's
|
||||
|
||||
@@ -6,17 +6,17 @@ project_name: "project_name"
|
||||
# bsl clojure cpp cpp_ccls crystal
|
||||
# csharp csharp_omnisharp cue dart deno
|
||||
# elixir elm erlang fortran fsharp
|
||||
# gdscript go groovy haskell haxe
|
||||
# hlsl html java json julia
|
||||
# kotlin latex lean4 lua luau
|
||||
# markdown matlab msl nextflow nix
|
||||
# ocaml pascal perl php php_phpactor
|
||||
# php_phpantom powershell python python_basedpyright python_jedi
|
||||
# python_pyrefly python_ty qml r rego
|
||||
# ruby ruby_solargraph rust scala scss
|
||||
# solidity svelte swift systemverilog terraform
|
||||
# toml typescript typescript_vts vue yaml
|
||||
# zig
|
||||
# gdscript gleam go groovy haskell
|
||||
# haxe hlsl html java json
|
||||
# julia kotlin latex lean4 lua
|
||||
# luau markdown matlab msl nextflow
|
||||
# nix ocaml pascal perl php
|
||||
# php_phpactor php_phpantom powershell python python_basedpyright
|
||||
# python_jedi python_pyrefly python_ty qml r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# scss solidity svelte swift systemverilog
|
||||
# terraform toml typescript typescript_vts vue
|
||||
# wolfram yaml zig
|
||||
# (This list may be outdated; generated with scripts/print_language_list.py;
|
||||
# For the current list, see values of the LanguageServerId enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -11,6 +12,18 @@ from sensai.util.logging import LogTime
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Characters meaningful to pathspec's gitignore grammar: glob wildcards, bracket expressions,
|
||||
# the escape character itself, and '!'/'#' which change a whole pattern's meaning when they
|
||||
# are its first character. Backslash-escaping them makes a literal name safe to interpolate.
|
||||
_GITIGNORE_PATTERN_SPECIAL_CHARS_RE = re.compile(r"([\\*?\[\]!#])")
|
||||
|
||||
|
||||
def _escape_gitignore_path_component(component: str) -> str:
|
||||
"""Escape gitignore/pathspec pattern metacharacters in a single path component (no
|
||||
separators) so it is matched as a literal name rather than as glob syntax.
|
||||
"""
|
||||
return _GITIGNORE_PATTERN_SPECIAL_CHARS_RE.sub(r"\\\1", component)
|
||||
|
||||
|
||||
class ScanResult(NamedTuple):
|
||||
"""Result of scanning a directory."""
|
||||
@@ -223,11 +236,21 @@ class GitignoreParser:
|
||||
"""
|
||||
patterns = []
|
||||
|
||||
# Get the relative path from repo root to the gitignore directory
|
||||
rel_dir = os.path.relpath(gitignore_dir, self.repo_root)
|
||||
# Get the relative path from repo root to the gitignore directory. Normalize to
|
||||
# forward slashes immediately: os.path.relpath returns native separators, but
|
||||
# gitignore/pathspec patterns are always POSIX-style, and on Windows os.sep is
|
||||
# backslash -- the same character pathspec uses as its escape character. Building
|
||||
# the pattern with any raw os.sep would make a later blanket backslash->slash
|
||||
# normalization indistinguishable from the escape backslashes below.
|
||||
rel_dir = os.path.relpath(gitignore_dir, self.repo_root).replace(os.sep, "/")
|
||||
if rel_dir == ".":
|
||||
rel_dir = ""
|
||||
|
||||
# rel_dir is a filesystem path, but the code below interpolates it into pattern
|
||||
# position; escape each of its components so a directory name containing pattern
|
||||
# metacharacters (e.g. "***") is matched literally instead of as glob syntax.
|
||||
rel_dir_pattern = "/".join(_escape_gitignore_path_component(part) for part in rel_dir.split("/")) if rel_dir else rel_dir
|
||||
|
||||
for line in content.splitlines():
|
||||
# Strip trailing whitespace (but preserve leading whitespace for now)
|
||||
line = line.rstrip()
|
||||
@@ -256,20 +279,23 @@ class GitignoreParser:
|
||||
if is_anchored:
|
||||
line = line[1:]
|
||||
|
||||
# Adjust pattern based on gitignore file location
|
||||
# Adjust pattern based on gitignore file location. Joined with a literal "/",
|
||||
# never os.path.join/os.sep: gitignore patterns are always POSIX-style, and on
|
||||
# Windows os.sep is backslash, indistinguishable from the escape backslashes
|
||||
# rel_dir_pattern may already contain.
|
||||
if rel_dir:
|
||||
if is_anchored:
|
||||
# Anchored patterns are relative to the gitignore directory
|
||||
adjusted_pattern = os.path.join(rel_dir, line)
|
||||
adjusted_pattern = f"{rel_dir_pattern}/{line}"
|
||||
else:
|
||||
# Non-anchored patterns can match anywhere below the gitignore directory
|
||||
# We need to preserve this behavior
|
||||
if line.startswith("**/"):
|
||||
# Even if pattern starts with **, it should still be scoped to the subdirectory
|
||||
adjusted_pattern = os.path.join(rel_dir, line)
|
||||
adjusted_pattern = f"{rel_dir_pattern}/{line}"
|
||||
else:
|
||||
# Add the directory prefix but also allow matching in subdirectories
|
||||
adjusted_pattern = os.path.join(rel_dir, "**", line)
|
||||
adjusted_pattern = f"{rel_dir_pattern}/**/{line}"
|
||||
else:
|
||||
if is_anchored:
|
||||
# Anchored patterns in root should only match at root level
|
||||
@@ -283,9 +309,6 @@ class GitignoreParser:
|
||||
if is_negation:
|
||||
adjusted_pattern = "!" + adjusted_pattern
|
||||
|
||||
# Normalize path separators to forward slashes (gitignore uses forward slashes)
|
||||
adjusted_pattern = adjusted_pattern.replace(os.sep, "/")
|
||||
|
||||
patterns.append(adjusted_pattern)
|
||||
|
||||
return patterns
|
||||
|
||||
@@ -88,6 +88,10 @@ class NextflowLanguageServer(SolidLanguageServer):
|
||||
self._active_progress_tokens: set[str] = set()
|
||||
self._progress_lock = threading.Lock()
|
||||
|
||||
# Whether the server's deferred workspace scan has been observed to complete
|
||||
# (see _flush_deferred_workspace_scan).
|
||||
self._workspace_scan_flushed = False
|
||||
|
||||
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
|
||||
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
|
||||
|
||||
@@ -297,6 +301,32 @@ class NextflowLanguageServer(SolidLanguageServer):
|
||||
else:
|
||||
log.warning("Nextflow LS did not signal scan completion within %.0fs; proceeding anyway", _WORKSPACE_SCAN_TIMEOUT)
|
||||
|
||||
def _flush_deferred_workspace_scan(self, relative_file_path: str) -> None:
|
||||
"""
|
||||
Forces the workspace scan, which the server defers past the first request of a session.
|
||||
|
||||
The scan runs in ``LanguageService.update0`` behind a 1s debounce, and only on a round with no
|
||||
pending file change; the session's first ``didOpen`` re-defers it, leaving references to be
|
||||
answered from an AST cache holding just that one file.
|
||||
|
||||
``completion`` calls ``updateNow``, which runs the update synchronously before replying, so two
|
||||
such requests force the scan whatever the workspace size: the first drains the pending change,
|
||||
the second finds none and scans. Their results are discarded.
|
||||
"""
|
||||
if self._workspace_scan_flushed:
|
||||
return
|
||||
|
||||
params: lsp_types.CompletionParams = {
|
||||
"textDocument": {"uri": self._resolve_file_uri(relative_file_path)},
|
||||
"position": {"line": 0, "character": 0},
|
||||
}
|
||||
for _ in range(2):
|
||||
try:
|
||||
self.server.send.completion(params)
|
||||
except Exception as e:
|
||||
log.debug("Completion request used to flush the Nextflow workspace scan failed: %s", e)
|
||||
self._workspace_scan_flushed = True
|
||||
|
||||
@override
|
||||
def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None:
|
||||
"""
|
||||
@@ -309,13 +339,14 @@ class NextflowLanguageServer(SolidLanguageServer):
|
||||
come back empty. Sending a ``documentSymbol`` request for the same file first is a cheap way to
|
||||
block until the update has been applied.
|
||||
"""
|
||||
self._flush_deferred_workspace_scan(relative_file_path)
|
||||
self.server.send.document_symbol({"textDocument": {"uri": self._resolve_file_uri(relative_file_path)}})
|
||||
return super()._send_references_request(relative_file_path, line, column)
|
||||
|
||||
@override
|
||||
def _get_wait_time_for_cross_file_referencing(self) -> float:
|
||||
"""Small safety buffer; the actual synchronisation happens in _send_references_request."""
|
||||
return 1.0
|
||||
"""No blind wait is needed: _send_references_request synchronises with the server explicitly."""
|
||||
return 0.0
|
||||
|
||||
@override
|
||||
def _document_symbols_cache_fingerprint(self) -> Hashable:
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Wolfram Language server integration using the official WolframResearch LSPServer paclet."""
|
||||
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
|
||||
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
|
||||
from solidlsp.ls_config import LanguageServerConfig
|
||||
from solidlsp.settings import SolidLSPSettings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
WOLFRAM_PATH_ENV_VAR = "WOLFRAM_PATH"
|
||||
|
||||
|
||||
class WolframLanguageServer(SolidLanguageServer):
|
||||
"""
|
||||
Wolfram Language server using the official WolframResearch LSPServer paclet
|
||||
(https://github.com/WolframResearch/LSPServer), which is bundled with Wolfram
|
||||
installations and communicates via stdio.
|
||||
|
||||
Requires Wolfram Mathematica 13.0+ or Wolfram Engine 12.1+.
|
||||
The WolframKernel executable is discovered via the ``ls_path`` entry in
|
||||
``ls_specific_settings.wolfram``, the ``WOLFRAM_PATH`` environment variable
|
||||
(pointing to the executable or the installation directory), the system PATH,
|
||||
or common installation locations.
|
||||
"""
|
||||
|
||||
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
|
||||
super().__init__(config, repository_root_path, None, "wolfram", solidlsp_settings)
|
||||
|
||||
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
|
||||
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
|
||||
|
||||
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
|
||||
def _get_or_install_core_dependency(self) -> str:
|
||||
"""
|
||||
Discover the WolframKernel executable.
|
||||
|
||||
:return: path to the WolframKernel executable
|
||||
:raises FileNotFoundError: if WolframKernel cannot be found
|
||||
"""
|
||||
return _find_wolfram_kernel()
|
||||
|
||||
def _create_launch_command(self, core_path: str) -> list[str]:
|
||||
return [core_path, "-noprompt", "-noinit", "-run", 'Needs["LSPServer`"];LSPServer`StartServer[]']
|
||||
|
||||
def _create_base_initialize_params(self) -> dict:
|
||||
return {
|
||||
"capabilities": {
|
||||
"workspace": {"workspaceFolders": True},
|
||||
"textDocument": {
|
||||
"definition": {"dynamicRegistration": True},
|
||||
"references": {"dynamicRegistration": True},
|
||||
"documentSymbol": {
|
||||
"dynamicRegistration": True,
|
||||
"hierarchicalDocumentSymbolSupport": True,
|
||||
},
|
||||
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
|
||||
"formatting": {"dynamicRegistration": True},
|
||||
"publishDiagnostics": {"relatedInformation": True},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _start_server(self) -> None:
|
||||
def do_nothing(params: dict) -> None:
|
||||
return
|
||||
|
||||
def window_log_message(msg: dict) -> None:
|
||||
log.info(f"Wolfram LSP: window/logMessage: {msg}")
|
||||
|
||||
self.server.on_request("client/registerCapability", do_nothing)
|
||||
self.server.on_notification("window/logMessage", window_log_message)
|
||||
self.server.on_notification("$/progress", do_nothing)
|
||||
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
|
||||
|
||||
log.info("Starting Wolfram LSPServer process")
|
||||
self.server.start()
|
||||
|
||||
initialize_params = self._create_initialize_params()
|
||||
init_response = self.server.send.initialize(initialize_params)
|
||||
|
||||
log.info(f"Wolfram LSP capabilities: {list(init_response.get('capabilities', {}).keys())}")
|
||||
|
||||
self.server.notify.initialized({})
|
||||
log.info("Wolfram LSPServer initialized and ready.")
|
||||
|
||||
|
||||
def _find_wolfram_kernel() -> str:
|
||||
"""Find the WolframKernel executable via WOLFRAM_PATH, the system PATH, or common install locations."""
|
||||
# 1. WOLFRAM_PATH environment variable (executable or installation directory)
|
||||
env_path = os.environ.get(WOLFRAM_PATH_ENV_VAR)
|
||||
if env_path:
|
||||
if os.path.isfile(env_path) and os.access(env_path, os.X_OK):
|
||||
log.info(f"Using WolframKernel from {WOLFRAM_PATH_ENV_VAR}: {env_path}")
|
||||
return env_path
|
||||
kernel_in_dir = _find_kernel_in_install_dir(env_path)
|
||||
if kernel_in_dir:
|
||||
log.info(f"Using WolframKernel from {WOLFRAM_PATH_ENV_VAR} directory: {kernel_in_dir}")
|
||||
return kernel_in_dir
|
||||
|
||||
# 2. System PATH
|
||||
kernel_path = shutil.which("WolframKernel")
|
||||
if kernel_path:
|
||||
log.info(f"Using WolframKernel from PATH: {kernel_path}")
|
||||
return kernel_path
|
||||
|
||||
# 3. Common installation locations
|
||||
system = platform.system()
|
||||
search_locations: list[str] = []
|
||||
|
||||
if system == "Darwin":
|
||||
search_locations = [
|
||||
"/Applications/Mathematica.app/Contents/MacOS/WolframKernel",
|
||||
"/Applications/Wolfram.app/Contents/MacOS/WolframKernel",
|
||||
"/Applications/Wolfram Engine.app/Contents/MacOS/WolframKernel",
|
||||
]
|
||||
for pattern in [
|
||||
"/Applications/Mathematica*.app/Contents/MacOS/WolframKernel",
|
||||
"/Applications/Wolfram*.app/Contents/MacOS/WolframKernel",
|
||||
]:
|
||||
search_locations.extend(sorted(glob.glob(pattern), reverse=True))
|
||||
|
||||
elif system == "Linux":
|
||||
search_locations = [
|
||||
"/usr/local/bin/WolframKernel",
|
||||
"/usr/bin/WolframKernel",
|
||||
]
|
||||
for pattern in [
|
||||
"/usr/local/Wolfram/Mathematica/*/Executables/WolframKernel",
|
||||
"/usr/local/Wolfram/WolframEngine/*/Executables/WolframKernel",
|
||||
"/opt/Wolfram/Mathematica/*/Executables/WolframKernel",
|
||||
]:
|
||||
search_locations.extend(sorted(glob.glob(pattern), reverse=True))
|
||||
|
||||
elif system == "Windows":
|
||||
for pattern in [
|
||||
"C:\\Program Files\\Wolfram Research\\Mathematica\\*\\WolframKernel.exe",
|
||||
"C:\\Program Files\\Wolfram Research\\Wolfram Engine\\*\\WolframKernel.exe",
|
||||
]:
|
||||
search_locations.extend(sorted(glob.glob(pattern), reverse=True))
|
||||
|
||||
for location in search_locations:
|
||||
if os.path.isfile(location) and os.access(location, os.X_OK):
|
||||
log.info(f"Found WolframKernel at: {location}")
|
||||
return location
|
||||
|
||||
raise FileNotFoundError(
|
||||
"WolframKernel not found. Please either:\n"
|
||||
f"1. Set the {WOLFRAM_PATH_ENV_VAR} environment variable to your Wolfram installation\n"
|
||||
"2. Add WolframKernel to your system PATH\n"
|
||||
"3. Configure ls_path in ls_specific_settings.wolfram\n"
|
||||
"4. Install Wolfram Mathematica (13.0+) or Wolfram Engine (12.1+) from https://www.wolfram.com/"
|
||||
)
|
||||
|
||||
|
||||
def _find_kernel_in_install_dir(install_dir: str) -> str | None:
|
||||
"""Try to locate WolframKernel within a Wolfram installation directory."""
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
candidates = [
|
||||
os.path.join(install_dir, "Contents", "MacOS", "WolframKernel"),
|
||||
os.path.join(install_dir, "MacOS", "WolframKernel"),
|
||||
]
|
||||
elif system == "Windows":
|
||||
candidates = [
|
||||
os.path.join(install_dir, "WolframKernel.exe"),
|
||||
]
|
||||
else:
|
||||
candidates = [
|
||||
os.path.join(install_dir, "Executables", "WolframKernel"),
|
||||
os.path.join(install_dir, "WolframKernel"),
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||
return candidate
|
||||
|
||||
return None
|
||||
+17
-6
@@ -2533,8 +2533,9 @@ class SolidLanguageServer(ABC):
|
||||
) -> ls_types.UnifiedSymbolInformation | None:
|
||||
"""
|
||||
Finds the first symbol containing the position for the given file.
|
||||
For Python, container symbols are considered to be those with kinds corresponding to
|
||||
functions, methods, or classes (typically: Function (12), Method (6), Class (5)).
|
||||
Container symbols are those with kinds corresponding to functions, methods, classes, structs
|
||||
and interfaces, as well as variables and constants (which may be one-liners, e.g. members
|
||||
of a Go ``const`` group).
|
||||
|
||||
The method operates as follows:
|
||||
- Request the document symbols for the file.
|
||||
@@ -2546,7 +2547,7 @@ class SolidLanguageServer(ABC):
|
||||
among those above the given line.
|
||||
- If no container candidates are found, return None.
|
||||
|
||||
:param relative_file_path: The relative path to the Python file.
|
||||
:param relative_file_path: The relative path to the file.
|
||||
:param line: The 0-indexed line number.
|
||||
:param column: The 0-indexed column (also called character). If not passed, the lookup will be based
|
||||
only on the line.
|
||||
@@ -2589,8 +2590,15 @@ class SolidLanguageServer(ABC):
|
||||
location["relativePath"] = relative_file_path
|
||||
location["uri"] = Path(absolute_file_path).as_uri()
|
||||
|
||||
# Allowed container kinds, currently only for Python
|
||||
container_symbol_kinds = {ls_types.SymbolKind.Method, ls_types.SymbolKind.Function, ls_types.SymbolKind.Class}
|
||||
# allowed container kinds; Struct and Interface cover languages (e.g. Go, Rust, C) whose
|
||||
# containers are not reported as classes
|
||||
container_symbol_kinds = {
|
||||
ls_types.SymbolKind.Method,
|
||||
ls_types.SymbolKind.Function,
|
||||
ls_types.SymbolKind.Class,
|
||||
ls_types.SymbolKind.Struct,
|
||||
ls_types.SymbolKind.Interface,
|
||||
}
|
||||
|
||||
def is_position_in_range(line: int, range_d: ls_types.Range) -> bool:
|
||||
start = range_d["start"]
|
||||
@@ -2613,7 +2621,10 @@ class SolidLanguageServer(ABC):
|
||||
for s in document_symbols.iter_symbols()
|
||||
if s["kind"] in container_symbol_kinds and s["location"]["range"]["start"]["line"] != s["location"]["range"]["end"]["line"]
|
||||
]
|
||||
var_containers = [s for s in document_symbols.iter_symbols() if s["kind"] == ls_types.SymbolKind.Variable]
|
||||
# variables and constants are admitted even as one-liners (e.g. members of a Go const group)
|
||||
var_containers = [
|
||||
s for s in document_symbols.iter_symbols() if s["kind"] in {ls_types.SymbolKind.Variable, ls_types.SymbolKind.Constant}
|
||||
]
|
||||
candidate_containers.extend(var_containers)
|
||||
|
||||
if not candidate_containers:
|
||||
|
||||
@@ -196,6 +196,11 @@ class LanguageServerId(str, Enum):
|
||||
it reports no symbols for them, so they are not treated as source files here).
|
||||
Automatically downloads the language server JAR; requires Java 17+ (JAVA_HOME or 'java' on PATH).
|
||||
"""
|
||||
WOLFRAM = "wolfram"
|
||||
"""Wolfram Language server using the official WolframResearch LSPServer paclet.
|
||||
Requires Wolfram Mathematica 13.0+ or Wolfram Engine 12.1+.
|
||||
Set WOLFRAM_PATH environment variable or configure ls_path in ls_specific_settings.
|
||||
"""
|
||||
# Experimental or deprecated Language Servers
|
||||
TYPESCRIPT_VTS = "typescript_vts"
|
||||
"""Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls"""
|
||||
@@ -598,6 +603,8 @@ class LanguageServerId(str, Enum):
|
||||
# only scripts: the language server does have a service for .config files, but it provides
|
||||
# no symbols for them, so treating them as source files would only pollute the symbol index
|
||||
return FilenameMatcher(".nf")
|
||||
case self.WOLFRAM:
|
||||
return FilenameMatcher(".wl", ".wls")
|
||||
case self.HTML:
|
||||
return FilenameMatcher(".html", ".htm")
|
||||
case self.SCSS:
|
||||
@@ -901,6 +908,10 @@ class LanguageServerId(str, Enum):
|
||||
from solidlsp.language_servers.nextflow_language_server import NextflowLanguageServer
|
||||
|
||||
return NextflowLanguageServer
|
||||
case self.WOLFRAM:
|
||||
from solidlsp.language_servers.wolfram_language_server import WolframLanguageServer
|
||||
|
||||
return WolframLanguageServer
|
||||
case self.HTML:
|
||||
from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer
|
||||
|
||||
|
||||
+25
-28
@@ -2,7 +2,6 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
@@ -32,6 +31,7 @@ from solidlsp.lsp_protocol_handler.server import (
|
||||
make_response,
|
||||
)
|
||||
from solidlsp.util import subprocess_util
|
||||
from solidlsp.util.subprocess_util import ManagedSubprocess
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -507,22 +507,21 @@ class StdioLanguageServer(LanguageServerInterface):
|
||||
"""
|
||||
super().__init__(ls_id, determine_log_level, logger, request_timeout)
|
||||
|
||||
self.process_launch_info = process_launch_info
|
||||
self.process: subprocess.Popen[bytes] | None = None
|
||||
self.start_independent_lsp_process = start_independent_lsp_process
|
||||
|
||||
self._process_launch_info = process_launch_info
|
||||
self._process: ManagedSubprocess[bytes] | None = None
|
||||
self._start_independent_lsp_process = start_independent_lsp_process
|
||||
self._stdin_lock = threading.Lock()
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self.process is not None and self.process.returncode is None
|
||||
return self._process is not None and self._process.returncode is None
|
||||
|
||||
def _start(self) -> None:
|
||||
log.info("Starting language server process via command: %s", self.process_launch_info.cmd)
|
||||
log.info("Starting language server process via command: %s", self._process_launch_info.cmd)
|
||||
|
||||
process = subprocess_util.LanguageServerSubprocessLauncher.get_instance().launch(
|
||||
self.process_launch_info, start_new_session=self.start_independent_lsp_process
|
||||
process = subprocess_util.ManagedSubprocessLauncher.get_instance().launch(
|
||||
self._process_launch_info, name=f"LS[{self.ls_id.value}]", start_new_session=self._start_independent_lsp_process
|
||||
)
|
||||
self.process = process
|
||||
self._process = process
|
||||
|
||||
# Check if process terminated immediately
|
||||
if process.returncode is not None:
|
||||
@@ -545,7 +544,7 @@ class StdioLanguageServer(LanguageServerInterface):
|
||||
).start()
|
||||
|
||||
def _stop(self, timeout: float) -> None:
|
||||
if self.process is None:
|
||||
if self._process is None:
|
||||
log.debug("Server process is None, cannot shutdown.")
|
||||
return
|
||||
|
||||
@@ -553,16 +552,14 @@ class StdioLanguageServer(LanguageServerInterface):
|
||||
# send LSP shutdown and close stdin to signal no more input
|
||||
try:
|
||||
self._send_shutdown_in_thread()
|
||||
self._safely_close_pipe(self.process.stdin)
|
||||
self._safely_close_pipe(self._process.stdin)
|
||||
except Exception as e:
|
||||
log.debug(f"Exception during graceful shutdown: {e}")
|
||||
# Ignore errors here, we are proceeding to terminate anyway.
|
||||
# terminate the process
|
||||
subprocess_util.terminate_process_tree_with_kill_fallback(
|
||||
self.process, terminate_timeout=timeout, process_name=f"LS[{self.ls_id.value}]"
|
||||
)
|
||||
self._process.terminate(timeout=timeout)
|
||||
finally:
|
||||
self.process = None
|
||||
self._process = None
|
||||
|
||||
@staticmethod
|
||||
def _safely_close_pipe(pipe: IO[AnyStr] | None) -> None:
|
||||
@@ -573,7 +570,7 @@ class StdioLanguageServer(LanguageServerInterface):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _read_bytes_from_process(self, process, stream, num_bytes) -> bytes:
|
||||
def _read_bytes_from_process(self, process: ManagedSubprocess[bytes], stream: IO[bytes], num_bytes: int) -> bytes:
|
||||
"""Read exactly num_bytes from process stdout"""
|
||||
data = b""
|
||||
while len(data) < num_bytes:
|
||||
@@ -597,10 +594,10 @@ class StdioLanguageServer(LanguageServerInterface):
|
||||
"""
|
||||
exception: Exception | None = None
|
||||
try:
|
||||
while self.process and self.process.stdout:
|
||||
if self.process.poll() is not None: # process has terminated
|
||||
while self._process and self._process.stdout:
|
||||
if self._process.poll() is not None: # process has terminated
|
||||
break
|
||||
line = self.process.stdout.readline()
|
||||
line = self._process.stdout.readline()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
@@ -610,10 +607,10 @@ class StdioLanguageServer(LanguageServerInterface):
|
||||
if num_bytes is None:
|
||||
continue
|
||||
while line and line.strip():
|
||||
line = self.process.stdout.readline()
|
||||
line = self._process.stdout.readline()
|
||||
if not line:
|
||||
continue
|
||||
body = self._read_bytes_from_process(self.process, self.process.stdout, num_bytes)
|
||||
body = self._read_bytes_from_process(self._process, self._process.stdout, num_bytes)
|
||||
|
||||
self._handle_body(body)
|
||||
except LanguageServerTerminatedException as e:
|
||||
@@ -636,11 +633,11 @@ class StdioLanguageServer(LanguageServerInterface):
|
||||
Continuously read from the language server process stderr and log the messages
|
||||
"""
|
||||
try:
|
||||
while self.process and self.process.stderr:
|
||||
if self.process.poll() is not None:
|
||||
while self._process and self._process.stderr:
|
||||
if self._process.poll() is not None:
|
||||
# process has terminated
|
||||
break
|
||||
line = self.process.stderr.readline()
|
||||
line = self._process.stderr.readline()
|
||||
if not line:
|
||||
continue
|
||||
line_str = line.decode(ENCODING, errors="replace")
|
||||
@@ -654,7 +651,7 @@ class StdioLanguageServer(LanguageServerInterface):
|
||||
log.info("Language server stderr reader thread has terminated")
|
||||
|
||||
def _send_payload(self, payload: StringDict) -> None:
|
||||
if not self.process or not self.process.stdin:
|
||||
if not self._process or not self._process.stdin:
|
||||
return
|
||||
self._trace("solidlsp", "ls", payload)
|
||||
msg = create_message(payload)
|
||||
@@ -662,8 +659,8 @@ class StdioLanguageServer(LanguageServerInterface):
|
||||
# Use lock to prevent concurrent writes to stdin that cause buffer corruption
|
||||
with self._stdin_lock:
|
||||
try:
|
||||
self.process.stdin.writelines(msg)
|
||||
self.process.stdin.flush()
|
||||
self._process.stdin.writelines(msg)
|
||||
self._process.stdin.flush()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError) as e:
|
||||
# Log the error but don't raise to prevent cascading failures
|
||||
log.error(f"Failed to write to stdin: {e}")
|
||||
|
||||
@@ -6,10 +6,11 @@ import signal
|
||||
import subprocess
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import IO, TYPE_CHECKING, Any, Generic, TypeVar, cast
|
||||
|
||||
import oslex
|
||||
import psutil
|
||||
from sensai.util.string import ToStringMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ctypes
|
||||
@@ -17,6 +18,7 @@ if TYPE_CHECKING:
|
||||
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
TStream = TypeVar("TStream", bound=str | bytes)
|
||||
|
||||
|
||||
def subprocess_kwargs() -> dict:
|
||||
@@ -70,9 +72,73 @@ def convert_shell_cmd(cmd: str | list[str]) -> str:
|
||||
return oslex.join(cmd) if isinstance(cmd, list) else cmd
|
||||
|
||||
|
||||
class LanguageServerSubprocessLauncher:
|
||||
class ManagedSubprocess(Generic[TStream], ToStringMixin):
|
||||
"""
|
||||
Launcher for language server subprocesses, which are started for stdio-based communication.
|
||||
Represents a subprocess.Popen instance with additional lifecycle management, including the ability to terminate the process
|
||||
and its children gracefully, with a fallback to forceful termination if necessary.
|
||||
"""
|
||||
|
||||
def __init__(self, popen: subprocess.Popen[TStream], name: str, start_new_session: bool) -> None:
|
||||
"""
|
||||
:param popen: the subprocess.Popen instance representing the launched process
|
||||
:param start_new_session: whether the process was launched in its own session, i.e. whether it is the
|
||||
leader of its own process group
|
||||
"""
|
||||
self._popen = popen
|
||||
self._name = name
|
||||
|
||||
# a process launched with start_new_session=True is its own group leader, so its PGID is its PID
|
||||
self._process_group_id = popen.pid if (start_new_session and os.name == "posix") else None
|
||||
|
||||
def _tostring_includes(self) -> list[str]:
|
||||
return ["_name"]
|
||||
|
||||
@property
|
||||
def stdin(self) -> "IO[TStream] | None":
|
||||
return self._popen.stdin
|
||||
|
||||
@property
|
||||
def stdout(self) -> "IO[TStream] | None":
|
||||
return self._popen.stdout
|
||||
|
||||
@property
|
||||
def stderr(self) -> "IO[TStream] | None":
|
||||
return self._popen.stderr
|
||||
|
||||
@property
|
||||
def returncode(self) -> int | None:
|
||||
"""
|
||||
:return: the exit code of the process as of the most recent status check (see :meth:`poll`),
|
||||
or None if the process was not yet known to have terminated
|
||||
"""
|
||||
return self._popen.returncode
|
||||
|
||||
def poll(self) -> int | None:
|
||||
"""
|
||||
Checks whether the process has terminated, updating :attr:`returncode` accordingly.
|
||||
|
||||
:return: the exit code of the process or None if it is still running
|
||||
"""
|
||||
return self._popen.poll()
|
||||
|
||||
def terminate(self, timeout: float) -> None:
|
||||
"""
|
||||
Terminates the process and its children, forcefully killing them if they do not exit in time.
|
||||
|
||||
:param timeout: the time to wait for the process to terminate gracefully before killing it
|
||||
:param process_name: the name of the process (used for logging purposes); should start with a capital letter
|
||||
"""
|
||||
terminate_process_tree_with_kill_fallback(
|
||||
self._popen,
|
||||
terminate_timeout=timeout,
|
||||
process_name=self._name,
|
||||
process_group_id=self._process_group_id,
|
||||
)
|
||||
|
||||
|
||||
class ManagedSubprocessLauncher:
|
||||
"""
|
||||
Launcher for managed subprocesses (see :class:`ManagedSubprocess`), which are started for stdio-based communication.
|
||||
It is home to the concern of launching a subprocess with well-defined lifecycle properties,
|
||||
ensuring, in particular, that a launched subprocess cannot outlive this process (insofar as
|
||||
the platform allows) -- even if the subprocess is started in its own session (see
|
||||
@@ -85,16 +151,16 @@ class LanguageServerSubprocessLauncher:
|
||||
_PR_SET_PDEATHSIG = 1
|
||||
"""the PR_SET_PDEATHSIG option value for prctl(2)"""
|
||||
|
||||
_instance: "LanguageServerSubprocessLauncher | None" = None
|
||||
_instance: "ManagedSubprocessLauncher | None" = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._libc = self._load_libc()
|
||||
self._spawner: "LanguageServerSubprocessLauncher._PDeathSigSpawner | None" = None
|
||||
self._spawner: "ManagedSubprocessLauncher._PDeathSigSpawner | None" = None
|
||||
self._spawner_lock = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> "LanguageServerSubprocessLauncher":
|
||||
def get_instance(cls) -> "ManagedSubprocessLauncher":
|
||||
if cls._instance is None:
|
||||
with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
@@ -115,7 +181,7 @@ class LanguageServerSubprocessLauncher:
|
||||
return ctypes.CDLL(None)
|
||||
except OSError as e:
|
||||
log.warning(
|
||||
"Could not load libc (%s); language server processes will not be protected against "
|
||||
"Could not load libc (%s); subprocesses will not be protected against "
|
||||
"orphaning if this process is killed without a chance to shut down cleanly",
|
||||
e,
|
||||
)
|
||||
@@ -130,11 +196,12 @@ class LanguageServerSubprocessLauncher:
|
||||
if self._libc is not None:
|
||||
self._libc.prctl(self._PR_SET_PDEATHSIG, signal.SIGTERM)
|
||||
|
||||
def launch(self, process_launch_info: "ProcessLaunchInfo", start_new_session: bool) -> subprocess.Popen[bytes]:
|
||||
def launch(self, process_launch_info: "ProcessLaunchInfo", name: str, start_new_session: bool) -> ManagedSubprocess[bytes]:
|
||||
"""
|
||||
Launches a language server process from ``process_launch_info``.
|
||||
Launches a subprocess from ``process_launch_info``.
|
||||
|
||||
:param process_launch_info: the command, environment and working directory to launch with
|
||||
:param name: the name of the process (used for logging purposes); should start with a capital letter
|
||||
:param start_new_session: whether to start the process in its own session (own process
|
||||
group, detached from ours)
|
||||
"""
|
||||
@@ -158,8 +225,8 @@ class LanguageServerSubprocessLauncher:
|
||||
if use_pdeathsig:
|
||||
kwargs["preexec_fn"] = self._set_pdeathsig_on_parent_exit
|
||||
|
||||
def do_popen() -> subprocess.Popen[bytes]:
|
||||
return cast(
|
||||
def do_popen() -> ManagedSubprocess[bytes]:
|
||||
popen = cast(
|
||||
"subprocess.Popen[bytes]",
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
@@ -172,6 +239,7 @@ class LanguageServerSubprocessLauncher:
|
||||
**kwargs,
|
||||
),
|
||||
)
|
||||
return ManagedSubprocess(popen, name, start_new_session)
|
||||
|
||||
# perform the actual Popen call, funneling the fork() through the dedicated spawner thread
|
||||
# when pdeathsig applies
|
||||
@@ -192,7 +260,7 @@ class LanguageServerSubprocessLauncher:
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._queue: queue.Queue[tuple[Callable[[], subprocess.Popen], "queue.Queue"]] = queue.Queue()
|
||||
self._queue: queue.Queue[tuple[Callable[[], ManagedSubprocess[bytes]], "queue.Queue"]] = queue.Queue()
|
||||
self._thread = threading.Thread(target=self._run, name="solidlsp-pdeathsig-spawner", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
@@ -204,7 +272,7 @@ class LanguageServerSubprocessLauncher:
|
||||
except BaseException as e:
|
||||
result_queue.put((e, None))
|
||||
|
||||
def spawn(self, func: Callable[[], subprocess.Popen]) -> subprocess.Popen:
|
||||
def spawn(self, func: Callable[[], ManagedSubprocess[bytes]]) -> ManagedSubprocess[bytes]:
|
||||
result_queue: queue.Queue = queue.Queue(maxsize=1)
|
||||
self._queue.put((func, result_queue))
|
||||
error, process = result_queue.get()
|
||||
@@ -246,7 +314,33 @@ def _signal_process_tree(process: subprocess.Popen[bytes], terminate: bool = Tru
|
||||
signal_process(process)
|
||||
|
||||
|
||||
def terminate_process_tree_with_kill_fallback(process: subprocess.Popen, terminate_timeout: float, process_name: str = "Process") -> None:
|
||||
def _signal_process_group(pgid: int, terminate: bool = True) -> None:
|
||||
"""
|
||||
Sends a signal to every process in the given POSIX process group by group ID, without
|
||||
enumerating the system process table. Requires the caller to know the group already exists
|
||||
and is owned by us (see ``terminate_process_tree_with_kill_fallback``'s ``process_group_id``).
|
||||
|
||||
:param pgid: the process group ID to signal
|
||||
:param terminate: if True, signal terminate (SIGTERM), otherwise signal kill (SIGKILL)
|
||||
"""
|
||||
sig = signal.SIGTERM if terminate else signal.SIGKILL
|
||||
try:
|
||||
os.killpg(pgid, sig)
|
||||
except ProcessLookupError:
|
||||
# the group is already gone; nothing left to signal
|
||||
pass
|
||||
except PermissionError as e:
|
||||
log.warning(f"Permission denied signaling process group {pgid} with {sig.name}: {e}")
|
||||
except Exception as e:
|
||||
log.warning(f"Unexpected error signaling process group {pgid} with {sig.name}: {e}")
|
||||
|
||||
|
||||
def terminate_process_tree_with_kill_fallback(
|
||||
process: subprocess.Popen,
|
||||
terminate_timeout: float,
|
||||
process_name: str = "Process",
|
||||
process_group_id: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Attempts to terminate the given process and its children by signaling them to terminate,
|
||||
and if that fails (i.e. they don't exit within the given timeout), forcefully kills them.
|
||||
@@ -256,9 +350,23 @@ def terminate_process_tree_with_kill_fallback(process: subprocess.Popen, termina
|
||||
:param process: the process to terminate
|
||||
:param terminate_timeout: the time to wait for the process to terminate gracefully before killing it
|
||||
:param process_name: the name of the process (used for logging purposes); should start with capital letter
|
||||
:param process_group_id: if given, the POSIX process group ID that ``process`` leads (i.e. it was
|
||||
launched with ``start_new_session=True``, so its PGID equals its PID). When set, cleanup
|
||||
signals the whole group directly via ``os.killpg`` instead of walking the process tree with
|
||||
``psutil``, which requires system-wide process-table enumeration (``sysctl(KERN_PROC_ALL)`` on
|
||||
macOS) that can be denied even for processes we started and own. Only pass this for a process
|
||||
that was started in its own session: signaling the group of a process that shares ours would
|
||||
also signal us.
|
||||
"""
|
||||
log.debug(f"Terminating process {process.pid}, current status: {process.poll()}")
|
||||
_signal_process_tree(process, terminate=True)
|
||||
|
||||
def signal_tree(terminate: bool) -> None:
|
||||
if process_group_id is not None:
|
||||
_signal_process_group(process_group_id, terminate=terminate)
|
||||
else:
|
||||
_signal_process_tree(process, terminate=terminate)
|
||||
|
||||
signal_tree(terminate=True)
|
||||
try:
|
||||
log.debug(f"Waiting for process {process.pid} to terminate...")
|
||||
exit_code = process.wait(timeout=terminate_timeout)
|
||||
@@ -266,7 +374,7 @@ def terminate_process_tree_with_kill_fallback(process: subprocess.Popen, termina
|
||||
except subprocess.TimeoutExpired:
|
||||
# If termination failed, forcefully kill the process
|
||||
log.warning(f"{process_name} (pid={process.pid}) termination timed out, killing process forcefully...")
|
||||
_signal_process_tree(process, terminate=False)
|
||||
signal_tree(terminate=False)
|
||||
try:
|
||||
exit_code = process.wait(timeout=2.0)
|
||||
log.info(f"{process_name} killed successfully with exit code {exit_code}.")
|
||||
|
||||
@@ -492,6 +492,15 @@ def _determine_disabled_language_servers() -> list[LanguageServerId]:
|
||||
if is_ci:
|
||||
result.append(LanguageServerId.KOTLIN) # IntelliJ-based Kotlin LSP crashes on JVM restart under CI memory limits
|
||||
|
||||
# Disable Wolfram tests if WolframKernel is not available (checked with the same
|
||||
# discovery logic used by the language server itself)
|
||||
from solidlsp.language_servers.wolfram_language_server import _find_wolfram_kernel
|
||||
|
||||
try:
|
||||
_find_wolfram_kernel()
|
||||
except FileNotFoundError:
|
||||
result.append(LanguageServerId.WOLFRAM)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
type Severity int
|
||||
|
||||
const (
|
||||
SevLow Severity = iota
|
||||
SevHigh
|
||||
)
|
||||
|
||||
type Alert struct {
|
||||
Level Severity
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (a Alert) IsHigh() bool {
|
||||
return a.Level == SevHigh
|
||||
}
|
||||
|
||||
type Notifier interface {
|
||||
Notify(level Severity) error
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
sayHello[name_String] := StringJoin["Hello, ", name, "!"]
|
||||
|
||||
formatResult[value_] := StringJoin["Result: ", ToString[value]]
|
||||
@@ -0,0 +1,17 @@
|
||||
Get["lib/helper.wl"]
|
||||
|
||||
calculateSum[a_, b_] := a + b
|
||||
|
||||
processData[data_List] := Module[{result},
|
||||
result = Total[data];
|
||||
formatResult[result]
|
||||
]
|
||||
|
||||
main[] := Module[{result, greeting},
|
||||
result = calculateSum[5, 3];
|
||||
greeting = sayHello["World"];
|
||||
Print[greeting];
|
||||
Print[result]
|
||||
]
|
||||
|
||||
main[]
|
||||
@@ -1,11 +1,13 @@
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pathspec import PathSpec
|
||||
|
||||
from serena.util.file_system import GitignoreParser, GitignoreSpec, match_path
|
||||
from serena.util.file_system import GitignoreParser, GitignoreSpec, _escape_gitignore_path_component, match_path
|
||||
|
||||
|
||||
class TestGitignoreParser:
|
||||
@@ -715,6 +717,81 @@ src/*.o
|
||||
# foo.txt in other/ should NOT be ignored (outside foo/ subtree)
|
||||
assert not parser.should_ignore("other/foo.txt"), "other/foo.txt should NOT be ignored by foo/.gitignore"
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="'*' is illegal in Windows filenames; this directory name cannot exist there")
|
||||
def test_gitignore_dir_name_with_glob_metachars_is_not_a_wildcard(self):
|
||||
"""A directory named with glob metacharacters (e.g. a stray '***' venv) must be
|
||||
matched literally, not interpreted as a pattern (issue #1806).
|
||||
"""
|
||||
test_dir = self.repo_path / "test_metachar_dirname"
|
||||
test_dir.mkdir()
|
||||
(test_dir / "pkg").mkdir()
|
||||
(test_dir / "***").mkdir()
|
||||
|
||||
(test_dir / "pkg" / "mod.py").touch()
|
||||
(test_dir / "***" / "junk.txt").touch()
|
||||
|
||||
gitignore = test_dir / "***" / ".gitignore"
|
||||
gitignore.write_text("*\n")
|
||||
|
||||
parser = GitignoreParser(str(test_dir))
|
||||
|
||||
# pkg/mod.py is outside the "***" directory and must not be affected by its gitignore
|
||||
assert not parser.should_ignore("pkg/mod.py"), "pkg/mod.py should not be ignored by ***/.gitignore"
|
||||
|
||||
# junk.txt inside "***" is still ignored by its own gitignore's "*" pattern
|
||||
assert parser.should_ignore("***/junk.txt"), "***/junk.txt should be ignored by its own gitignore"
|
||||
|
||||
def test_gitignore_dir_name_with_metachars_anchored_pattern(self):
|
||||
"""Same as above, for the anchored-pattern join site."""
|
||||
test_dir = self.repo_path / "test_metachar_dirname_anchored"
|
||||
test_dir.mkdir()
|
||||
(test_dir / "pkg").mkdir()
|
||||
(test_dir / "a[1]").mkdir()
|
||||
|
||||
(test_dir / "pkg" / "mod.py").touch()
|
||||
(test_dir / "a[1]" / "mod.py").touch()
|
||||
|
||||
gitignore = test_dir / "a[1]" / ".gitignore"
|
||||
gitignore.write_text("/mod.py\n")
|
||||
|
||||
parser = GitignoreParser(str(test_dir))
|
||||
|
||||
assert not parser.should_ignore("pkg/mod.py"), "pkg/mod.py should not be ignored by a[1]/.gitignore"
|
||||
assert parser.should_ignore("a[1]/mod.py"), "a[1]/mod.py should be ignored by its own /mod.py pattern"
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="'?' is illegal in Windows filenames; this directory name cannot exist there")
|
||||
def test_gitignore_dir_name_with_metachars_implicit_double_star_pattern(self):
|
||||
"""A non-anchored pattern with no leading '**/' is joined as (rel_dir, "**", line):
|
||||
the third join site. An unescaped '?' in the directory name would leak the pattern
|
||||
into a sibling directory whose name merely matches the wildcard (issue #1806).
|
||||
"""
|
||||
test_dir = self.repo_path / "test_metachar_dirname_implicit_doublestar"
|
||||
test_dir.mkdir()
|
||||
(test_dir / "q?").mkdir()
|
||||
(test_dir / "qA").mkdir()
|
||||
(test_dir / "qA" / "sub").mkdir()
|
||||
|
||||
(test_dir / "qA" / "sub" / "mod.py").touch()
|
||||
|
||||
gitignore = test_dir / "q?" / ".gitignore"
|
||||
gitignore.write_text("mod.py\n")
|
||||
|
||||
parser = GitignoreParser(str(test_dir))
|
||||
|
||||
# "q?/.gitignore" must not reach into the sibling "qA/" directory just because "?"
|
||||
# would match the "A" in "qA" if left as a wildcard.
|
||||
assert not parser.should_ignore("qA/sub/mod.py"), "qA/sub/mod.py should not be ignored by q?/.gitignore"
|
||||
|
||||
def test_escape_gitignore_path_component_escapes_all_metachars(self):
|
||||
"""Pure-function coverage for the '*' and '?' escaping cases the two directory-creation
|
||||
tests above cannot exercise on Windows (those characters are illegal in Windows
|
||||
filenames, so this runs on every platform instead of touching the filesystem).
|
||||
"""
|
||||
assert _escape_gitignore_path_component("***") == "\\*\\*\\*"
|
||||
assert _escape_gitignore_path_component("q?") == "q\\?"
|
||||
assert _escape_gitignore_path_component("a[1]") == "a\\[1\\]"
|
||||
assert _escape_gitignore_path_component("plain") == "plain"
|
||||
|
||||
|
||||
class TestGitignoreParserPermissionError:
|
||||
"""Test PermissionError handling in GitignoreParser."""
|
||||
|
||||
@@ -4,11 +4,13 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from serena.symbol import LanguageServerSymbol
|
||||
from serena.util.text_utils import find_text_coordinates
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import LanguageServerId
|
||||
from solidlsp.ls_types import SymbolKind
|
||||
from solidlsp.ls_utils import SymbolUtils
|
||||
from test.conftest import find_identifier_position, get_repo_path, ls_has_verified_implementation_support
|
||||
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
|
||||
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, read_repo_file, request_all_symbols
|
||||
|
||||
|
||||
@pytest.mark.go
|
||||
@@ -88,6 +90,62 @@ class TestGoLanguageServer:
|
||||
assert body.startswith(name), f"Expected grouped var {name} body to start with the identifier, got {body[:24]!r}"
|
||||
assert not body.startswith("var"), f"Grouped var {name} body must not include the 'var' keyword"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [LanguageServerId.GO], indirect=True)
|
||||
def test_request_containing_symbol_const_group_member(self, language_server: SolidLanguageServer) -> None:
|
||||
"""A reference inside a ``const`` group must be attributed to the enclosing constant."""
|
||||
file_path = os.path.join("containment_sample.go")
|
||||
file_content = read_repo_file(language_server, file_path)
|
||||
coords = find_text_coordinates(file_content, r"SevLow (Severity) = iota")
|
||||
assert coords is not None, "Could not find the Severity reference in the const group"
|
||||
containing_symbol = language_server.request_containing_symbol(file_path, coords.line, coords.col)
|
||||
assert containing_symbol is not None, "Expected a containing symbol for a reference inside a const group"
|
||||
assert containing_symbol["name"] == "SevLow"
|
||||
assert containing_symbol["kind"] == SymbolKind.Constant
|
||||
|
||||
@pytest.mark.parametrize("language_server", [LanguageServerId.GO], indirect=True)
|
||||
def test_request_containing_symbol_struct_field(self, language_server: SolidLanguageServer) -> None:
|
||||
"""A reference in a struct field declaration must be attributed to the enclosing struct."""
|
||||
file_path = os.path.join("containment_sample.go")
|
||||
file_content = read_repo_file(language_server, file_path)
|
||||
coords = find_text_coordinates(file_content, r"Level (Severity)")
|
||||
assert coords is not None, "Could not find the Severity reference in the struct field"
|
||||
containing_symbol = language_server.request_containing_symbol(file_path, coords.line, coords.col)
|
||||
assert containing_symbol is not None, "Expected a containing symbol for a reference inside a struct body"
|
||||
assert containing_symbol["name"] == "Alert"
|
||||
assert containing_symbol["kind"] == SymbolKind.Struct
|
||||
|
||||
@pytest.mark.parametrize("language_server", [LanguageServerId.GO], indirect=True)
|
||||
def test_request_containing_symbol_interface_method(self, language_server: SolidLanguageServer) -> None:
|
||||
"""A reference in an interface method signature must be attributed to the enclosing interface."""
|
||||
file_path = os.path.join("containment_sample.go")
|
||||
file_content = read_repo_file(language_server, file_path)
|
||||
coords = find_text_coordinates(file_content, r"Notify\(level (Severity)\)")
|
||||
assert coords is not None, "Could not find the Severity reference in the interface method"
|
||||
containing_symbol = language_server.request_containing_symbol(file_path, coords.line, coords.col)
|
||||
assert containing_symbol is not None, "Expected a containing symbol for a reference inside an interface body"
|
||||
assert containing_symbol["name"] == "Notifier"
|
||||
assert containing_symbol["kind"] == SymbolKind.Interface
|
||||
|
||||
@pytest.mark.parametrize("language_server", [LanguageServerId.GO], indirect=True)
|
||||
def test_request_referencing_symbols_attributes_containers(self, language_server: SolidLanguageServer) -> None:
|
||||
"""References to a type used in a const group, a struct field and an interface method must
|
||||
be attributed to the constant, the struct and the interface respectively, not to the file.
|
||||
"""
|
||||
file_path = os.path.join("containment_sample.go")
|
||||
all_symbols, _ = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
|
||||
severity_symbol = next((sym for sym in all_symbols if sym.get("name") == "Severity"), None)
|
||||
assert severity_symbol is not None, "Could not find the 'Severity' type symbol in containment_sample.go"
|
||||
sel_start = severity_symbol["selectionRange"]["start"]
|
||||
ref_symbols = [
|
||||
ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
|
||||
]
|
||||
assert ref_symbols, "Expected references to the Severity type"
|
||||
ref_names = {ref["name"] for ref in ref_symbols}
|
||||
assert "SevLow" in ref_names
|
||||
assert "Alert" in ref_names
|
||||
assert "Notifier" in ref_names
|
||||
assert all(ref["kind"] != SymbolKind.File for ref in ref_symbols), f"File-level fallback attribution in {ref_names}"
|
||||
|
||||
if ls_has_verified_implementation_support(LanguageServerId.GO):
|
||||
|
||||
@pytest.mark.parametrize("language_server", [LanguageServerId.GO], indirect=True)
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import LanguageServerId
|
||||
from solidlsp.ls_utils import SymbolUtils
|
||||
from test.conftest import start_default_ls_context
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("java") is None, reason="Nextflow language server requires a Java runtime")
|
||||
|
||||
@@ -74,6 +75,25 @@ class TestNextflowLanguageServer:
|
||||
# main.nf, 0-indexed line 12: greetings = GREET(names)
|
||||
assert ("main.nf", 12) in ref_lines, f"invocation of GREET in main.nf not found. Found: {ref_lines}"
|
||||
|
||||
def test_find_references_across_files_on_a_fresh_server(self) -> None:
|
||||
"""Cross-file references must not depend on another request having opened the referencing file.
|
||||
|
||||
The module-scoped ``language_server`` fixture is shared, so by the time
|
||||
``test_find_references_across_files`` runs, earlier tests have already opened main.nf and thereby
|
||||
forced the server to compile it. This test starts its own server and touches nothing but the
|
||||
defining file, which is what a real session's first request looks like.
|
||||
"""
|
||||
module_path = os.path.join("modules", "greet", "main.nf")
|
||||
with start_default_ls_context(LanguageServerId.NEXTFLOW) as language_server:
|
||||
symbol = self._get_symbol(language_server, module_path, "GREET")
|
||||
start = symbol["selectionRange"]["start"]
|
||||
|
||||
refs = language_server.request_references(module_path, start["line"], start["character"])
|
||||
|
||||
ref_lines = self._reference_lines(refs)
|
||||
# main.nf, 0-indexed line 12: greetings = GREET(names)
|
||||
assert ("main.nf", 12) in ref_lines, f"invocation of GREET in main.nf not found. Found: {ref_lines}"
|
||||
|
||||
@staticmethod
|
||||
def _reference_lines(references: list[dict]) -> set[tuple[str, int]]:
|
||||
"""(relative path, 0-indexed start line) per reference; paths use the platform separator."""
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Regression tests for issue #1818: LSP process-group cleanup must not require enumerating
|
||||
the system process table (``psutil.Process.children(recursive=True)``), which can be denied
|
||||
even for processes we started and own (``Operation not permitted`` from
|
||||
``sysctl(KERN_PROC_ALL)`` in a sandboxed macOS environment).
|
||||
|
||||
``StdioLanguageServer`` already starts every LSP process in its own session
|
||||
(``start_independent_lsp_process`` defaults to True, see ``ls_config.py``), which makes the
|
||||
process its own POSIX process group leader, with a PGID equal to its PID at launch;
|
||||
``subprocess_util.terminate_process_tree_with_kill_fallback`` accepts that PGID as
|
||||
``process_group_id`` and, when given, signals the whole group directly via ``os.killpg``
|
||||
instead of walking the tree with ``psutil``. No language markers: these exercise
|
||||
``subprocess_util`` directly with plain Python helper processes and run in catch-all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
|
||||
import psutil
|
||||
import pytest
|
||||
|
||||
from solidlsp.util.subprocess_util import _signal_process_group, terminate_process_tree_with_kill_fallback
|
||||
|
||||
pytestmark = pytest.mark.skipif(platform.system() == "Windows", reason="process groups / os.killpg are POSIX-specific")
|
||||
|
||||
|
||||
def _group_is_gone(pgid: int) -> bool:
|
||||
try:
|
||||
os.killpg(pgid, 0)
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
return True
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout: float = 5.0, interval: float = 0.1) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return predicate()
|
||||
|
||||
|
||||
def _process_alive(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
|
||||
|
||||
class _DenyingProcess:
|
||||
"""Stand-in for ``psutil.Process`` that always raises ``AccessDenied``, used to simulate
|
||||
a sandboxed environment denying process-table enumeration without needing one. A real class
|
||||
(not a plain function) so it substitutes cleanly for ``psutil.Process`` in the
|
||||
``subprocess.Popen | psutil.Process`` type union that subprocess_util.py evaluates eagerly.
|
||||
"""
|
||||
|
||||
def __new__(cls, pid: int) -> "_DenyingProcess":
|
||||
raise psutil.AccessDenied(pid)
|
||||
|
||||
|
||||
def _spawn_ready(src: str) -> subprocess.Popen:
|
||||
"""Starts ``src`` in its own session and waits for it to print READY, mirroring
|
||||
test_pdeathsig.py's driver pattern (deterministic sync instead of a blind sleep).
|
||||
"""
|
||||
proc = subprocess.Popen([sys.executable, "-c", src], start_new_session=True, stdout=subprocess.PIPE, text=True)
|
||||
ready_line = proc.stdout.readline()
|
||||
assert ready_line.strip() == "READY", f"helper process failed to start: {ready_line!r}"
|
||||
return proc
|
||||
|
||||
|
||||
class TestSignalProcessGroup:
|
||||
def test_nonexistent_group_is_treated_as_already_clean(self) -> None:
|
||||
bogus_pgid = 2**30 # not a real PGID; must be handled like an already-gone group
|
||||
_signal_process_group(bogus_pgid, terminate=True) # must not raise
|
||||
|
||||
def test_permission_error_is_caught_and_logged(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def raise_eperm(pgid: int, sig: int) -> None:
|
||||
raise PermissionError("simulated sandbox denial")
|
||||
|
||||
monkeypatch.setattr(os, "killpg", raise_eperm)
|
||||
_signal_process_group(12345, terminate=True) # must not raise
|
||||
|
||||
|
||||
class TestTerminateProcessTreeWithKillFallback:
|
||||
def test_terminates_child_and_grandchild_via_group_id(self) -> None:
|
||||
"""A focused POSIX cleanup test: start a child and grandchild in one new session,
|
||||
invoke the cleanup utility by PGID only, and verify both are gone, without ever
|
||||
calling psutil.
|
||||
"""
|
||||
src = textwrap.dedent(
|
||||
"""
|
||||
import subprocess, sys, time
|
||||
subprocess.Popen([sys.executable, "-c", "import time; time.sleep(300)"])
|
||||
print("READY", flush=True)
|
||||
time.sleep(300)
|
||||
"""
|
||||
)
|
||||
proc = _spawn_ready(src)
|
||||
pgid = proc.pid
|
||||
try:
|
||||
assert not _group_is_gone(pgid), "process group should be alive before cleanup"
|
||||
terminate_process_tree_with_kill_fallback(proc, terminate_timeout=5.0, process_group_id=pgid)
|
||||
assert _wait_until(lambda: _group_is_gone(pgid)), f"process group {pgid} survived cleanup"
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2.0)
|
||||
|
||||
def test_falls_back_to_kill_when_group_ignores_sigterm(self) -> None:
|
||||
src = textwrap.dedent(
|
||||
"""
|
||||
import signal, time
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
print("READY", flush=True)
|
||||
time.sleep(300)
|
||||
"""
|
||||
)
|
||||
proc = _spawn_ready(src)
|
||||
pgid = proc.pid
|
||||
try:
|
||||
terminate_process_tree_with_kill_fallback(proc, terminate_timeout=1.0, process_group_id=pgid)
|
||||
assert _wait_until(lambda: _group_is_gone(pgid)), f"process group {pgid} survived SIGKILL fallback"
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2.0)
|
||||
|
||||
def test_already_exited_group_does_not_raise(self) -> None:
|
||||
proc = subprocess.Popen([sys.executable, "-c", "pass"], start_new_session=True)
|
||||
pgid = proc.pid
|
||||
proc.wait(timeout=5.0)
|
||||
assert _wait_until(lambda: _group_is_gone(pgid)), "group should be released once the leader is reaped"
|
||||
terminate_process_tree_with_kill_fallback(proc, terminate_timeout=1.0, process_group_id=pgid)
|
||||
|
||||
def test_process_group_id_none_never_calls_killpg(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""start_independent_lsp_process=False means the process shares our own process
|
||||
group, so cleanup must never call killpg: doing so could signal Serena itself.
|
||||
"""
|
||||
|
||||
def fail_if_called(pgid: int, sig: int) -> None:
|
||||
raise AssertionError("os.killpg must not be called when process_group_id is None")
|
||||
|
||||
monkeypatch.setattr(os, "killpg", fail_if_called)
|
||||
proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(300)"])
|
||||
try:
|
||||
terminate_process_tree_with_kill_fallback(proc, terminate_timeout=5.0, process_group_id=None)
|
||||
proc.wait(timeout=5.0)
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2.0)
|
||||
|
||||
|
||||
class TestPsutilDenialConsequences:
|
||||
"""Demonstrates the actual production consequence when process-table enumeration is denied,
|
||||
without depending on macOS: ``psutil.AccessDenied`` is the same exception class regardless of
|
||||
which syscall the platform used to deny it. Without a known ``process_group_id``,
|
||||
``_signal_process_tree`` falls back to signaling only the ``Popen`` object it was given (see
|
||||
its ``except (psutil.NoSuchProcess, psutil.AccessDenied, Exception): pass`` branch), so a
|
||||
child the leader spawned itself leaks. Passing the group id (this fix) avoids psutil
|
||||
entirely and reaps it regardless.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _spawn_leader_with_child() -> tuple[subprocess.Popen, int]:
|
||||
src = textwrap.dedent(
|
||||
"""
|
||||
import subprocess, sys, time
|
||||
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(300)"])
|
||||
print(child.pid, flush=True)
|
||||
print("READY", flush=True)
|
||||
time.sleep(300)
|
||||
"""
|
||||
)
|
||||
proc = subprocess.Popen([sys.executable, "-c", src], start_new_session=True, stdout=subprocess.PIPE, text=True)
|
||||
child_pid = int(proc.stdout.readline().strip())
|
||||
ready_line = proc.stdout.readline()
|
||||
assert ready_line.strip() == "READY", f"helper process failed to start: {ready_line!r}"
|
||||
return proc, child_pid
|
||||
|
||||
def test_psutil_denial_without_group_id_leaks_the_leaders_child(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
proc, child_pid = self._spawn_leader_with_child()
|
||||
|
||||
monkeypatch.setattr("solidlsp.util.subprocess_util.psutil.Process", _DenyingProcess)
|
||||
try:
|
||||
# The call shape every site used before this fix: no process_group_id.
|
||||
terminate_process_tree_with_kill_fallback(proc, terminate_timeout=2.0, process_name="leader")
|
||||
assert _wait_until(lambda: proc.poll() is not None), "leader itself should still die (direct signal, not enumerated)"
|
||||
time.sleep(0.3)
|
||||
assert _process_alive(child_pid), (
|
||||
"expected the leader's own child to leak when psutil is denied and no process_group_id is given "
|
||||
"(this is the #1818 defect: process-table denial silently drops descendants)"
|
||||
)
|
||||
finally:
|
||||
for pid in (child_pid, proc.pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
if proc.poll() is None:
|
||||
proc.wait(timeout=2.0)
|
||||
|
||||
def test_process_group_id_survives_psutil_denial(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
proc, child_pid = self._spawn_leader_with_child()
|
||||
pgid = proc.pid
|
||||
|
||||
monkeypatch.setattr("solidlsp.util.subprocess_util.psutil.Process", _DenyingProcess)
|
||||
try:
|
||||
terminate_process_tree_with_kill_fallback(proc, terminate_timeout=5.0, process_group_id=pgid)
|
||||
assert _wait_until(lambda: _group_is_gone(pgid)), f"process group {pgid} survived cleanup despite psutil denial"
|
||||
assert not _process_alive(child_pid), "leader's child leaked even though the group id path avoids psutil entirely"
|
||||
finally:
|
||||
for pid in (child_pid, proc.pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
if proc.poll() is None:
|
||||
proc.wait(timeout=2.0)
|
||||
Whitespace-only changes.
@@ -0,0 +1,66 @@
|
||||
import pytest
|
||||
|
||||
from solidlsp.ls import SolidLanguageServer
|
||||
from solidlsp.ls_config import LanguageServerId
|
||||
from test.conftest import language_server_tests_enabled
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.wolfram,
|
||||
pytest.mark.skipif(
|
||||
not language_server_tests_enabled(LanguageServerId.WOLFRAM), reason="Wolfram tests disabled (WolframKernel not available)"
|
||||
),
|
||||
]
|
||||
|
||||
# Note: the LSPServer paclet computes textDocument/references per document, i.e. only
|
||||
# references within the file containing the queried position are returned; cross-file
|
||||
# references are not supported by the language server.
|
||||
|
||||
|
||||
class TestWolframLanguageServer:
|
||||
@pytest.mark.parametrize("language_server", [LanguageServerId.WOLFRAM], indirect=True)
|
||||
def test_wolfram_symbols(self, language_server: SolidLanguageServer):
|
||||
"""
|
||||
Test if we can find the top-level symbols in the main.wl file.
|
||||
"""
|
||||
all_symbols, _ = language_server.request_document_symbols("main.wl").get_all_symbols_and_roots()
|
||||
symbol_names = {s["name"] for s in all_symbols}
|
||||
assert "calculateSum" in symbol_names
|
||||
assert "main" in symbol_names
|
||||
|
||||
@pytest.mark.parametrize("language_server", [LanguageServerId.WOLFRAM], indirect=True)
|
||||
def test_wolfram_symbols_in_subdirectory(self, language_server: SolidLanguageServer):
|
||||
"""
|
||||
Test if we can find the top-level symbols in a file in a subdirectory.
|
||||
"""
|
||||
all_symbols, _ = language_server.request_document_symbols("lib/helper.wl").get_all_symbols_and_roots()
|
||||
symbol_names = {s["name"] for s in all_symbols}
|
||||
assert "sayHello" in symbol_names
|
||||
assert "formatResult" in symbol_names
|
||||
|
||||
@pytest.mark.parametrize("language_server", [LanguageServerId.WOLFRAM], indirect=True)
|
||||
def test_wolfram_within_file_references(self, language_server: SolidLanguageServer):
|
||||
"""
|
||||
Test finding references to a function within the same file.
|
||||
"""
|
||||
# 'calculateSum' is defined on line 2 of main.wl and called on line 10 (0-based)
|
||||
references = language_server.request_references("main.wl", line=2, column=0)
|
||||
|
||||
reference_lines = {(ref["relativePath"], ref["range"]["start"]["line"]) for ref in references}
|
||||
assert ("main.wl", 2) in reference_lines, f"Expected the definition site among references, got {reference_lines}"
|
||||
assert ("main.wl", 10) in reference_lines, f"Expected the call site among references, got {reference_lines}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [LanguageServerId.WOLFRAM], indirect=True)
|
||||
def test_wolfram_no_cross_file_references(self, language_server: SolidLanguageServer):
|
||||
"""
|
||||
Test documenting that references are computed per document by the LSPServer paclet.
|
||||
|
||||
'sayHello' is defined in lib/helper.wl and called in main.wl, but querying references
|
||||
from its definition must only return locations within lib/helper.wl. If this test
|
||||
starts failing, the language server gained cross-file reference support and the
|
||||
documentation (docs page, CHANGELOG, module comment above) should be updated.
|
||||
"""
|
||||
references = language_server.request_references("lib/helper.wl", line=0, column=0)
|
||||
|
||||
assert references, "Expected at least one within-file reference in lib/helper.wl fixture"
|
||||
reference_paths = {ref["relativePath"] for ref in references}
|
||||
assert reference_paths == {"lib/helper.wl"}, f"Expected references only within lib/helper.wl, got {reference_paths}"
|
||||
Reference in new issue
Block a user