From 6f86ae308e63d476902234d2cda0648b1dc41171 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 3 Sep 2026 13:21:15 +0000 Subject: [PATCH] fix(solidity): isolate Hardhat state on macOS (#1910) * fix(solidity): isolate Hardhat state on macOS --------- Co-authored-by: Yu-0312 <2.66498311e+08+Yu-0312@users.noreply.github.com> --- CHANGELOG.md | 3 + docs/02-usage/050_configuration.md | 11 +++ .../solidity_homedir_preload.cjs | 11 +++ .../solidity_language_server.py | 39 +++++++- .../test_solidity_dependency_provider.py | 91 +++++++++++++++++++ 5 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 src/solidlsp/language_servers/solidity_homedir_preload.cjs create mode 100644 test/solidlsp/solidity/test_solidity_dependency_provider.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c5ab69..dc7f35ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ Status of the `main` branch. Changes prior to the next official version change w Serena's own tools to close the gap (#1852) * Language Servers: + - Fix: the managed Solidity language server could report no diagnostics on macOS when Hardhat could not write + its global state under ``~/Library``; Serena now gives the child process an isolated home-directory view + via ``solidity_state_dir`` without changing the parent process's ``HOME`` (#1817) - Add Fatou support as an alternative Julia language server (`julia_fatou`) - Fix: Nextflow's `_flush_deferred_workspace_scan` marked the workspace scan flushed even when both of its `completion` probes failed, permanently skipping the flush (and silencing retries) for the diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index cc558e41..980cc7a8 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -1164,8 +1164,19 @@ Supported settings: |---|---|---| | `ls_path` | managed install | Override the Solidity language server executable path. | | `solidity_language_server_version` | `0.8.4` | Override the npm package version Serena installs when `ls_path` is not set. | +| `solidity_state_dir` | `/solidity-state` on macOS | Writable state root for the managed Solidity language server on macOS. Serena uses a child-process-only home-directory override so Hardhat does not write to `~/Library`; `HOME` in the Serena process is unchanged. | | `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. | +On macOS, if the default Solid-LSP resources directory is not writable, configure an alternative path: + +```yaml +ls_specific_settings: + solidity: + solidity_state_dir: /path/to/writable/solidity-state +``` + +This setting is ignored on Linux and Windows, where the existing launch environment is unchanged. + #### SystemVerilog Serena uses `verible-verilog-ls` for SystemVerilog support. diff --git a/src/solidlsp/language_servers/solidity_homedir_preload.cjs b/src/solidlsp/language_servers/solidity_homedir_preload.cjs new file mode 100644 index 00000000..63759502 --- /dev/null +++ b/src/solidlsp/language_servers/solidity_homedir_preload.cjs @@ -0,0 +1,11 @@ +"use strict"; + +const os = require("node:os"); +const stateRoot = process.env.SERENA_SOLIDITY_STATE_DIR; + +if (stateRoot) { + // env-paths uses os.homedir() on Darwin and does not honor XDG_* variables. + // Replace only this child process's view of the home directory; Serena's + // parent environment and the user's HOME remain unchanged. + os.homedir = () => stateRoot; +} diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py index 434c536a..657b1b9c 100644 --- a/src/solidlsp/language_servers/solidity_language_server.py +++ b/src/solidlsp/language_servers/solidity_language_server.py @@ -38,6 +38,12 @@ class SolidityLanguageServer(SolidLanguageServer): the Nomic Foundation Solidity Language Server (@nomicfoundation/solidity-language-server). Supports go-to-definition, find references, document symbols, hover, and diagnostics. Requires Node.js and npm to be installed. + + The ``solidity_state_dir`` setting can be used on macOS to choose the writable + state root for the managed Solidity language server. By default, Serena uses + ``/solidity-state``. This is needed because Hardhat's + ``env-paths`` dependency resolves its Darwin paths from ``os.homedir()`` and + ignores the XDG directory variables. """ _VALIDATION_COMPLETION_TIMEOUT = 60.0 @@ -84,6 +90,8 @@ class SolidityLanguageServer(SolidLanguageServer): return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) class DependencyProvider(LanguageServerDependencyProviderSinglePath): + _HOMEDIR_PRELOAD = os.path.join(os.path.dirname(__file__), "solidity_homedir_preload.cjs") + def _get_or_install_core_dependency(self) -> str: """ Install @nomicfoundation/solidity-language-server via npm and return the @@ -174,7 +182,36 @@ class SolidityLanguageServer(SolidLanguageServer): forge_version = self._custom_settings.get("forge_version", DEFAULT_FORGE_VERSION) solidity_ls_dir = self._resolve_solidity_ls_dir(solidity_language_server_version, forge_version) managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin") - return {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")} + launch_env = {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")} + + if platform.system() == "Darwin": + state_dir = self._get_solidity_state_dir() + os.makedirs(state_dir, exist_ok=True) + launch_env["SERENA_SOLIDITY_STATE_DIR"] = state_dir + preload_arg = f"--require {self._quote_node_option_argument(self._HOMEDIR_PRELOAD)}" + existing_node_options = os.environ.get("NODE_OPTIONS", "").strip() + launch_env["NODE_OPTIONS"] = f"{existing_node_options} {preload_arg}".strip() + + return launch_env + + @staticmethod + def _quote_node_option_argument(value: str) -> str: + """Quote a value for Node's NODE_OPTIONS parser. + + Node parses NODE_OPTIONS independently from the shell. POSIX single-quote escaping + produced by ``shlex.quote`` is therefore not understood by Node. Use double quotes, + escaping the two characters that have special meaning inside Node's double-quoted + option values. + """ + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + def _get_solidity_state_dir(self) -> str: + configured_state_dir = self._custom_settings.get("solidity_state_dir", os.path.join(self._ls_resources_dir, "solidity-state")) + if not isinstance(configured_state_dir, str): + raise TypeError("solidity_state_dir must be a path string") + if not configured_state_dir: + raise ValueError("solidity_state_dir must not be empty") + return os.path.abspath(os.path.expanduser(configured_state_dir)) def _resolve_solidity_ls_dir(self, solidity_language_server_version: str, forge_version: str) -> str: # legacy unversioned dir reserved for INITIAL pair; any other combination goes into a versioned subdir diff --git a/test/solidlsp/solidity/test_solidity_dependency_provider.py b/test/solidlsp/solidity/test_solidity_dependency_provider.py new file mode 100644 index 00000000..413e5d24 --- /dev/null +++ b/test/solidlsp/solidity/test_solidity_dependency_provider.py @@ -0,0 +1,91 @@ +import os +import platform +import shutil +import subprocess +from pathlib import Path + +import pytest + +from solidlsp.language_servers.solidity_language_server import SolidityLanguageServer +from solidlsp.settings import SolidLSPSettings + + +@pytest.fixture +def provider(tmp_path: Path) -> SolidityLanguageServer.DependencyProvider: + return SolidityLanguageServer.DependencyProvider( + SolidLSPSettings.CustomLSSettings({}), + str(tmp_path / "language-server-resources"), + ) + + +def test_darwin_uses_isolated_state_dir_and_preserves_node_options(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + state_dir = tmp_path / "solidity state" + existing_node_options = "--max-old-space-size=2048 --trace-warnings" + monkeypatch.setattr(platform, "system", lambda: "Darwin") + monkeypatch.setenv("NODE_OPTIONS", existing_node_options) + + provider = SolidityLanguageServer.DependencyProvider( + SolidLSPSettings.CustomLSSettings({"solidity_state_dir": str(state_dir)}), + str(tmp_path / "language-server-resources"), + ) + + launch_env = provider.create_launch_command_env() + + assert launch_env["SERENA_SOLIDITY_STATE_DIR"] == str(state_dir) + assert state_dir.is_dir() + assert launch_env["NODE_OPTIONS"] == ( + f"{existing_node_options} --require {provider._quote_node_option_argument(provider._HOMEDIR_PRELOAD)}" + ) + assert "HOME" not in launch_env + assert os.environ["NODE_OPTIONS"] == existing_node_options + + +@pytest.mark.skipif(shutil.which("node") is None, reason="Node.js is required for this test") +def test_darwin_node_options_loads_preload_path_with_spaces(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(platform, "system", lambda: "Darwin") + monkeypatch.delenv("NODE_OPTIONS", raising=False) + + preload_path = tmp_path / "solidity state" / "preload.cjs" + preload_path.parent.mkdir() + preload_path.write_text("console.log('PRELOAD_LOADED_OK')", encoding="utf-8") + provider = SolidityLanguageServer.DependencyProvider( + SolidLSPSettings.CustomLSSettings({"solidity_state_dir": str(tmp_path / "state dir")}), + str(tmp_path / "language-server-resources"), + ) + monkeypatch.setattr(provider, "_HOMEDIR_PRELOAD", str(preload_path)) + + launch_env = provider.create_launch_command_env() + result = subprocess.run( + ["node", "-e", "console.log('NODE_STARTED_OK')"], + env={**os.environ, **launch_env}, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + + assert result.returncode == 0, result.stderr + assert "PRELOAD_LOADED_OK" in result.stdout + assert "NODE_STARTED_OK" in result.stdout + + +def test_darwin_defaults_state_dir_under_language_server_resources( + provider: SolidityLanguageServer.DependencyProvider, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + launch_env = provider.create_launch_command_env() + + assert launch_env["SERENA_SOLIDITY_STATE_DIR"] == str(Path(provider._ls_resources_dir) / "solidity-state") + assert Path(launch_env["SERENA_SOLIDITY_STATE_DIR"]).is_dir() + + +def test_non_darwin_does_not_add_state_isolation( + monkeypatch: pytest.MonkeyPatch, provider: SolidityLanguageServer.DependencyProvider +) -> None: + monkeypatch.setattr(platform, "system", lambda: "Linux") + + launch_env = provider.create_launch_command_env() + + assert "SERENA_SOLIDITY_STATE_DIR" not in launch_env + assert "NODE_OPTIONS" not in launch_env