Svelte + TypeScript: fail loudly on companion TS timeouts; deterministic test fixture

Make the Svelte companion TypeScript server raise on readiness and
indexing timeouts instead of silently proceeding with a partial index,
turning flaky/wrong cross-file results into clear failures:

- TypeScriptLanguageServer: timeout handling factored into overridable
  hooks (_handle_server_ready_timeout, _handle_project_indexing_timeout);
  the base server keeps its historical permissive behavior, so plain
  TypeScript setups are unchanged.
- SvelteTypeScriptServer: both overrides raise TimeoutError; only the
  indexing-timeout error includes describe_indexing_state(), instead of
  serving requests from a cold or partially indexed program.
- Configurable server_ready_timeout / indexing_timeout via
  ls_specific_settings.
- SvelteLanguageServer: file-open failures during companion preparation
  raise SvelteCompanionPreparationError with a count-first file listing
  (capped at 10), chained to the first underlying error so the cause is
  visible without DEBUG logging.

Apply the same fail-loudly principle to the test fixture bootstrap:

- Svelte test repo: npm ci with a committed package-lock.json, removing
  dependency-resolution non-determinism from CI runs.
- Run svelte-kit sync explicitly during fixture setup and fail loudly if
  .svelte-kit/tsconfig.json cannot be generated. The fixture's "prepare"
  script masks sync failures ("svelte-kit sync || echo ''"), and a known
  npm bug (npm/cli#4828) can silently skip platform-specific optional
  dependencies; the result is unresolvable $lib path aliases and
  cross-file tests failing with partial results instead of a clear,
  actionable error.
- codespell: skip third-party fixture repositories, using the anchored
  pattern ./test/resources/repos (codespell matches skip globs against
  the ./-prefixed paths it walks, so an anchor-less pattern never
  matches).

Also add direct regression tests for the timeout-policy split
(test/solidlsp/test_typescript_timeout_policy.py: base stays
permissive, companion raises, settings plumbing and precedence, and
start-or-completion indexing-wait semantics). The tests use no mocking or
patching of any kind; a hand-written call-recording fake drives the companion
guard against real file discovery under a pytest tmp_path. They spawn no LS
process and avoid external readiness races; one wait test uses a real Timer
with a 30s ceiling.

The configuration docs state that the TypeScript table lists both knobs; the
Svelte table lists indexing_timeout, with readiness inheritance documented in
prose.
This commit is contained in:
xormania committed 2026-07-11 18:06:17 -04:00
1 parent e08e964d0c
commit 9580a6aec7
11 files changed
+2358 -60

No files matched your search

+5
View File
@@ -63,6 +63,11 @@ Status of the `main` branch. Changes prior to the next official version change w
processed by the Svelte LS instead of the TypeScript LS.
- `SvelteLanguageServer`: Fix document-symbol requests for TypeScript/JavaScript files returning empty
results in svelte-only mode (`languages: [svelte]`. #1552
- Svelte + TypeScript: make companion TypeScript server raise on readiness and indexing timeouts (instead
of silent "proceeding anyway"), so that partial indexing surfaces as a clear failure instead of flaky/wrong
cross-file results; add configurable timeouts (`server_ready_timeout`, `indexing_timeout`)
and overridable timeout hooks (base TS stays permissive). Svelte test fixture now uses `npm ci` +
committed `package-lock.json`.
- `JuliaLanguageServer`: Fix the stdio MCP server exiting right after `initialize` ("tools fetch failed")
when `julia` is enabled. #1577
- `Java`: invalidate JDTLS workspace cache when Java import settings change #1576
+8
View File
@@ -1031,6 +1031,7 @@ Supported settings:
| `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` npm package version Serena installs when `ls_path` is not set. |
| `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. |
| `indexing_timeout` | `30.0` | Timeout in seconds for waiting on tsserver's `$/progress` project-indexing signal (both at startup and before the first cross-file reference query). If indexing does not complete within this window, Serena logs a warning and proceeds anyway. Increase it for very large projects. |
| `server_ready_timeout` | `10.0` | Timeout in seconds for waiting on the server-ready signal after initialization. If the signal does not arrive within this window, Serena logs a message and proceeds anyway. |
#### Svelte
@@ -1048,8 +1049,15 @@ Supported settings:
| `typescript_language_server_version` | `5.1.3` (falls back to `ls_specific_settings.typescript.typescript_language_server_version`) | Override the `typescript-language-server` npm package version for the companion server. |
| `typescript_svelte_plugin_version` | `0.3.52` | Override the `typescript-svelte-plugin` npm package version used for `.svelte`-aware TS resolution. |
| `npm_registry` | `null` | Override the npm registry Serena uses for all managed installs. |
| `indexing_timeout` | `120.0` (falls back to `ls_specific_settings.typescript.indexing_timeout`) | Timeout in seconds for the companion TS server to finish indexing `.svelte` files. On timeout, startup fails with a diagnostic indexing-state summary instead of serving cross-file results from a partially indexed program. |
| `initialization_options_configuration` | `{}` | Deep-merge overrides for any of the ten plugin configuration sections (`svelte`, `prettier`, `emmet`, `typescript`, `javascript`, `js/ts`, `css`, `less`, `scss`, `html`). |
Unlike the plain TypeScript server, the companion is strict about readiness: it raises on server-ready and
indexing timeouts instead of proceeding with a cold or partially indexed program (which would silently degrade
cross-file renames and references). The companion reads `server_ready_timeout` and `indexing_timeout` from
`ls_specific_settings.typescript` with raised defaults (30s and 120s respectively); `ls_specific_settings.svelte.indexing_timeout`
takes precedence for the `.svelte`-file indexing wait.
All four packages are tracked via a version file; changing any version setting triggers a clean reinstall.
#### TypeScript via `vtsls`
+3 -1
View File
@@ -382,7 +382,9 @@ markers = [
[tool.codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = '.git*,*.svg,*.lock,*.min.*,*test_memories_manager.py'
# We skip third-party test fixture repositories entirely (they contain
# external package lockfiles, minified code, and arbitrary dependency strings).
skip = '.git*,*.svg,*.lock,*.min.*,*test_memories_manager.py,./test/resources/repos'
check-hidden = true
ignore-regex = '\.\w+'
ignore-words-list = 'paket,EDN,als,ue'
@@ -34,6 +34,12 @@ log = logging.getLogger(__name__)
TS_EXT = frozenset({".ts", ".tsx", ".mts", ".cts"})
JS_EXT = frozenset({".js", ".jsx", ".mjs", ".cjs"})
SVELTE_EXT = frozenset({".svelte"})
# cap the file listing in SvelteCompanionPreparationError so a large repo cannot bloat the message
_MAX_FAILED_FILES_IN_ERROR = 10
class SvelteCompanionPreparationError(RuntimeError):
"""Raised when the companion TypeScript server cannot be prepared deterministically."""
def _is_ts_file(uri: str) -> bool:
@@ -54,6 +60,9 @@ class SvelteTypeScriptServer(TypeScriptLanguageServer):
Spawned and owned by :class:`SvelteLanguageServer`; not instantiated directly.
"""
INDEXING_PROGRESS_TIMEOUT = 120.0
SERVER_READY_TIMEOUT = 30.0
class DependencyProvider(TypeScriptLanguageServer.DependencyProvider):
"""Returns the pre-installed typescript-language-server binary.
@@ -142,6 +151,16 @@ class SvelteTypeScriptServer(TypeScriptLanguageServer):
self.server.on_request("workspace/configuration", workspace_configuration_handler)
super()._start_server()
@override
def _handle_server_ready_timeout(self, timeout: float) -> None:
raise TimeoutError(f"Svelte companion TypeScript server did not become ready within {timeout:.0f}s")
@override
def _handle_project_indexing_timeout(self, timeout: float) -> None:
raise TimeoutError(
f"Svelte companion TypeScript server project indexing did not complete within {timeout:.0f}s ({self.describe_indexing_state()})"
)
class SvelteLanguageServer(SolidLanguageServer):
"""
@@ -151,6 +170,9 @@ class SvelteLanguageServer(SolidLanguageServer):
* ``svelte_language_server_version``: version of ``svelte-language-server``
to install (default: ``0.18.0``).
* ``npm_registry``: optional alternative npm-compatible registry URL.
* ``indexing_timeout``: optional timeout in seconds for companion TS indexing of
Svelte files. Falls back to ``ls_specific_settings["typescript"].indexing_timeout``
or the Svelte companion default.
* ``initialization_options_configuration``: optional dict merged into
``initializeParams.initializationOptions.configuration`` (same top-level keys as in
Svelte Language Tools: ``svelte``, ``prettier``, ``typescript``, …).
@@ -313,6 +335,15 @@ class SvelteLanguageServer(SolidLanguageServer):
log.debug("Error processing svelte file %s: %s", svelte_file, exc)
return svelte_files
def _get_companion_indexing_timeout(self) -> float:
""":return: maximum seconds to wait for companion TS indexing after opening Svelte files."""
ts_settings = self._solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT)
timeout = self._custom_settings.get(
"indexing_timeout",
ts_settings.get("indexing_timeout", SvelteTypeScriptServer.INDEXING_PROGRESS_TIMEOUT),
)
return float(timeout)
def _ensure_svelte_files_indexed_on_ts_server(self) -> None:
"""Open all .svelte files on the companion TS server so the plugin includes them in the TS program.
@@ -332,6 +363,8 @@ class SvelteLanguageServer(SolidLanguageServer):
# prepare progress tracking BEFORE opening files to avoid a race
self._ts_server.expect_indexing()
failed_svelte_files = []
first_open_error: Exception | None = None
for svelte_file in svelte_files:
try:
with self._ts_server.open_file(svelte_file) as file_buffer:
@@ -339,15 +372,29 @@ class SvelteLanguageServer(SolidLanguageServer):
self._indexed_svelte_file_uris.append(file_buffer.uri)
except Exception as exc:
log.debug("Failed to open %s on companion TS server: %s", svelte_file, exc)
if first_open_error is None:
first_open_error = exc
failed_svelte_files.append(svelte_file)
if failed_svelte_files:
shown_files = sorted(failed_svelte_files)[:_MAX_FAILED_FILES_IN_ERROR]
remainder = len(failed_svelte_files) - len(shown_files)
listing = ", ".join(shown_files) + (f" and {remainder} more" if remainder else "")
raise SvelteCompanionPreparationError(
f"Failed to open {len(failed_svelte_files)} Svelte file(s) on companion TypeScript server: {listing}"
) from first_open_error
self._svelte_files_indexed = True
log.info("Svelte file indexing complete; waiting for companion TS server to finish processing")
timeout = TypeScriptLanguageServer.INDEXING_PROGRESS_TIMEOUT
if self._ts_server.wait_for_indexing(timeout=timeout):
timeout = self._get_companion_indexing_timeout()
if self._ts_server._wait_for_indexing_start_or_completion(timeout=timeout):
log.info("Companion TypeScript server finished indexing .svelte files")
else:
log.warning("Timeout (%ss) waiting for companion TS server to index .svelte files; proceeding anyway", timeout)
raise TimeoutError(
f"Companion TypeScript server did not finish indexing {len(svelte_files)} .svelte files within {timeout:.0f}s "
f"({self._ts_server.describe_indexing_state()})"
)
def _cleanup_indexed_svelte_files(self) -> None:
"""Decrement ref-counts for all .svelte files opened during indexing."""
@@ -384,13 +431,13 @@ class SvelteLanguageServer(SolidLanguageServer):
)
log.info("Starting companion SvelteTypeScriptServer")
self._ts_server.start()
log.info("Waiting for companion SvelteTypeScriptServer to be ready ...")
if not self._ts_server.server_ready.wait(timeout=30.0):
log.warning("Timeout waiting for companion SvelteTypeScriptServer; proceeding anyway")
self._ts_server.server_ready.set()
self._ts_server_started = True
log.info("Companion SvelteTypeScriptServer ready")
self._ensure_svelte_files_indexed_on_ts_server()
except (TimeoutError, SvelteCompanionPreparationError):
log.exception("Failed to prepare companion SvelteTypeScriptServer; aborting Svelte server startup")
self._stop_typescript_server()
raise
except Exception:
log.exception("Error starting companion SvelteTypeScriptServer; TS-side operations degrade to svelte LS")
self._ts_server = None
@@ -73,6 +73,7 @@ class TypeScriptLanguageServer(SolidLanguageServer):
- typescript_version: Version of TypeScript to install (default: "5.9.3")
- typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3")
- indexing_timeout: float, timeout in seconds for project indexing (default: 30.0)
- server_ready_timeout: float, timeout in seconds for the server-ready signal (default: 10.0)
"""
@classmethod
@@ -82,6 +83,7 @@ class TypeScriptLanguageServer(SolidLanguageServer):
# Safety timeout for $/progress-based indexing wait. Normally the event fires
# well within this window; the timeout is only hit if the server never sends progress.
INDEXING_PROGRESS_TIMEOUT = 30.0
SERVER_READY_TIMEOUT = 10.0
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
@@ -114,6 +116,35 @@ class TypeScriptLanguageServer(SolidLanguageServer):
"""
return self._indexing_complete.wait(timeout=timeout)
def _wait_for_indexing_start_or_completion(self, timeout: float, start_grace: float | None = None) -> bool:
"""Wait until TypeScript indexing has started and drained, or provably never started.
:param timeout: Maximum seconds to wait once active indexing progress is observed.
:param start_grace: Maximum seconds to wait for progress to begin after opening files.
:return: True if indexing completed or no progress began within the grace period, False on timeout.
"""
grace = self._INDEXING_START_GRACE_S if start_grace is None else start_grace
# wait for progress to begin
progress_deadline = time.monotonic() + grace
while time.monotonic() < progress_deadline:
with self._progress_lock:
if self._active_progress_tokens:
break
if self._indexing_complete.is_set():
return True
time.sleep(0.05)
# treat absent progress as ready
with self._progress_lock:
has_active_progress = bool(self._active_progress_tokens)
if not has_active_progress:
self._indexing_complete.set()
return True
# wait for active progress to drain
return self.wait_for_indexing(timeout=timeout)
def expect_indexing(self) -> None:
"""Signal that new files are about to be opened and async indexing should be awaited.
@@ -123,6 +154,44 @@ class TypeScriptLanguageServer(SolidLanguageServer):
"""
self._indexing_complete.clear()
def describe_indexing_state(self) -> str:
""":return: compact diagnostic state for TypeScript indexing progress."""
with self._progress_lock:
active_tokens = sorted(token for token in self._active_progress_tokens if token)
complete = self._indexing_complete.is_set()
token_text = ", ".join(active_tokens) if active_tokens else "<none>"
return f"complete={complete}, active_progress_tokens={token_text}"
def _get_server_ready_timeout(self) -> float:
""":return: maximum seconds to wait for the TypeScript server-ready signal."""
return float(self._custom_settings.get("server_ready_timeout", self.SERVER_READY_TIMEOUT))
def _get_indexing_timeout(self) -> float:
""":return: maximum seconds to wait for TypeScript project indexing."""
return float(self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT))
def _handle_server_ready_timeout(self, timeout: float) -> None:
"""Handle a TypeScript server-ready timeout.
The base TypeScript server keeps the historical permissive behavior. Strict companion
servers override this hook to fail before serving requests from a cold server.
"""
log.info("Timeout waiting for TypeScript server to become ready after %.0fs, proceeding anyway", timeout)
self.server_ready.set()
def _handle_project_indexing_timeout(self, timeout: float) -> None:
"""Handle a TypeScript project-indexing timeout.
The base TypeScript server keeps the historical permissive behavior. Strict companion
servers override this hook to fail before serving requests from a partially indexed program.
"""
log.warning(
"TypeScript project indexing did not complete within %.0fs; proceeding anyway (%s)",
timeout,
self.describe_indexing_state(),
)
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
@@ -383,26 +452,22 @@ class TypeScriptLanguageServer(SolidLanguageServer):
}
self.server.notify.initialized({})
if self.server_ready.wait(timeout=10.0):
server_ready_timeout = self._get_server_ready_timeout()
if self.server_ready.wait(timeout=server_ready_timeout):
log.info("TypeScript server is ready")
else:
log.info("Timeout waiting for TypeScript server to become ready, proceeding anyway")
# Fallback: assume server is ready after timeout
self.server_ready.set()
self._handle_server_ready_timeout(server_ready_timeout)
# Wait for any async project loading to complete.
# typescript-language-server may send $/progress for "Initializing JS/TS
# language features…" after initialized. If no progress is sent,
# _indexing_complete stays SET and wait() returns immediately.
indexing_timeout = self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT)
indexing_timeout = self._get_indexing_timeout()
log.info("Waiting for TypeScript project indexing to complete (if async)...")
if self.wait_for_indexing(timeout=indexing_timeout):
log.info("TypeScript project indexing complete")
else:
log.warning(
"TypeScript project indexing did not complete within %.0fs; proceeding anyway",
indexing_timeout,
)
self._handle_project_indexing_timeout(indexing_timeout)
self._activate_additional_workspaces()
@@ -438,10 +503,15 @@ class TypeScriptLanguageServer(SolidLanguageServer):
@override
def _wait_for_additional_workspace_indexing(self) -> None:
if self.wait_for_indexing(timeout=self.INDEXING_PROGRESS_TIMEOUT):
timeout = self._get_indexing_timeout()
if self.wait_for_indexing(timeout=timeout):
log.info("Additional workspace indexing complete")
else:
log.warning("Additional workspace indexing did not complete within timeout; proceeding anyway")
log.warning(
"Additional workspace indexing did not complete within %.0fs; proceeding anyway (%s)",
timeout,
self.describe_indexing_state(),
)
@override
def _get_published_diagnostics_uri(self, request_uri: str) -> str:
@@ -470,19 +540,14 @@ class TypeScriptLanguageServer(SolidLanguageServer):
def _wait_for_cross_file_references_if_needed(self) -> None:
if self._has_waited_for_cross_file_references:
return
# Give tsserver a short grace period to start reporting $/progress after the file
# was opened. If progress starts, wait until it drains (bounded by indexing_timeout);
# if it never starts, tsserver considers the project loaded and we proceed after the
# grace period — which is no worse than the previous fixed 2-second wait.
grace_deadline = time.monotonic() + self._INDEXING_START_GRACE_S
while time.monotonic() < grace_deadline and not self._indexing_complete.is_set() and not self._active_progress_tokens:
time.sleep(0.05)
if self._active_progress_tokens:
timeout = self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT)
if self.wait_for_indexing(timeout=timeout):
log.info("TypeScript cross-file indexing complete")
else:
log.warning("TypeScript cross-file indexing did not complete within %.0fs; proceeding", timeout)
timeout = self._get_indexing_timeout()
if self._wait_for_indexing_start_or_completion(timeout=timeout):
log.info("TypeScript cross-file indexing complete")
else:
log.warning(
"TypeScript cross-file indexing did not complete within %.0fs; proceeding (%s)", timeout, self.describe_indexing_state()
)
self._has_waited_for_cross_file_references = True
@override
@@ -23,6 +23,5 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Lock files
package-lock.json
pnpm-lock.yaml
yarn.lock
yarn.lock
File diff suppressed because it is too large. Load diff
+58 -20
View File
@@ -13,41 +13,79 @@ log = logging.getLogger(__name__)
repo_path = Path(__file__).resolve().parents[2] / "resources" / "repos" / "svelte" / "test_repo"
NODE_MODULES = repo_path / "node_modules"
PACKAGE_LOCK = repo_path / "package-lock.json"
SVELTE_MARKER = NODE_MODULES / "svelte" / "package.json"
SVELTE_KIT_ADAPTER_MARKER = NODE_MODULES / "@sveltejs" / "adapter-auto" / "package.json"
SVELTE_KIT_TSCONFIG = repo_path / ".svelte-kit" / "tsconfig.json"
INSTALL_LOCK = repo_path / ".svelte-install.lock"
def _fixture_ready() -> bool:
return SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists() and SVELTE_KIT_TSCONFIG.exists()
def _run_svelte_kit_sync(npm_executable: str) -> None:
"""Generate .svelte-kit (notably its tsconfig.json carrying the $lib path aliases), failing loudly on error.
The fixture's own ``prepare`` script masks sync failures (``svelte-kit sync || echo ''``); a missing
``.svelte-kit/tsconfig.json`` leaves the fixture tsconfig's ``extends`` dangling, tsserver silently loses
the ``$lib`` path aliases, and cross-file tests fail with partial results that look like LS flakes.
"""
sync = subprocess.run(
[npm_executable, "exec", "--", "svelte-kit", "sync"],
cwd=str(repo_path),
capture_output=True,
text=True,
check=False,
env=os.environ.copy(),
)
if sync.returncode != 0 or not SVELTE_KIT_TSCONFIG.exists():
pytest.fail(
f"svelte-kit sync failed (rc={sync.returncode}) or did not produce {SVELTE_KIT_TSCONFIG}; "
"without it the $lib path aliases do not resolve and cross-file svelte tests fail with partial results.\n"
"Known cause: npm silently skipping platform-specific optional dependencies (npm/cli#4828), which "
"leaves rolldown without its native binding; remedy: remove node_modules and reinstall.\n"
f"stdout:\n{sync.stdout}\nstderr:\n{sync.stderr}"
)
log.info("svelte-kit sync succeeded; %s is present", SVELTE_KIT_TSCONFIG)
@pytest.fixture(scope="session", autouse=True)
def _install_svelte_test_repo_node_modules() -> None:
"""Populate the Svelte fixture's project dependencies via npm."""
if SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists():
log.info("Svelte test repo node_modules already populated; skipping npm install")
"""Populate the Svelte fixture's project dependencies via npm and generate .svelte-kit."""
if _fixture_ready():
log.info("Svelte test repo node_modules and .svelte-kit already populated; skipping npm install")
return
npm_executable = shutil.which("npm.cmd") or shutil.which("npm")
if npm_executable is None:
pytest.skip("npm is not available; cannot install Svelte test repo dependencies")
if not PACKAGE_LOCK.exists():
pytest.fail(f"Svelte fixture lockfile is missing: {PACKAGE_LOCK}. Regenerate it before running npm ci.")
with FileLock(str(INSTALL_LOCK)):
if SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists():
log.info("Svelte test repo node_modules populated by another worker; skipping npm install")
if _fixture_ready():
log.info("Svelte test repo dependencies populated by another worker; skipping npm install")
return
log.warning("Installing npm dependencies into the Svelte test repo at %s.", repo_path)
proc = subprocess.run(
[npm_executable, "install"],
cwd=str(repo_path),
capture_output=True,
text=True,
check=False,
env=os.environ.copy(),
)
if proc.returncode != 0:
log.error("npm install failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr)
pytest.skip(f"npm install failed in {repo_path} (rc={proc.returncode}); see logs for details")
if not SVELTE_MARKER.exists() or not SVELTE_KIT_ADAPTER_MARKER.exists():
pytest.skip("npm install completed but required Svelte fixture packages are missing")
log.warning("Installing npm dependencies into the Svelte test repo at %s with npm ci.", repo_path)
proc = subprocess.run(
[npm_executable, "ci"],
cwd=str(repo_path),
capture_output=True,
text=True,
check=False,
env=os.environ.copy(),
)
if proc.returncode != 0:
log.error("npm ci failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr)
pytest.skip(f"npm ci failed in {repo_path} (rc={proc.returncode}); see logs for details")
log.info("Svelte test repo node_modules installed successfully")
if not SVELTE_MARKER.exists() or not SVELTE_KIT_ADAPTER_MARKER.exists():
pytest.skip("npm ci completed but required Svelte fixture packages are missing")
log.info("Svelte test repo node_modules installed successfully")
_run_svelte_kit_sync(npm_executable)
+3 -2
View File
@@ -76,9 +76,10 @@ class TestSvelteLanguageServer:
coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(count)")
definitions = language_server.request_definition(file_path, coords.line, coords.col)
definition_paths = sorted(definition["relativePath"].replace("\\", "/") for definition in definitions)
assert len(definitions) == 1, definitions
assert definitions[0]["relativePath"].replace("\\", "/") == "src/lib/components/Counter.svelte"
assert len(definitions) == 1, definition_paths
assert definitions[0]["relativePath"].replace("\\", "/") == "src/lib/components/Counter.svelte", definition_paths
@pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True)
def test_diagnostics_in_typescript_file(self, language_server: SolidLanguageServer) -> None:
@@ -14,13 +14,13 @@ class TestSvelteReferences:
refs = language_server.request_references(os.path.join("src", "lib", "components", "Words.svelte"), 1, 17)
ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs}
assert "src/routes/(sverdle)/words.server.ts" in ref_paths
assert "src/lib/game.ts" in ref_paths
assert "src/routes/(sverdle)/+page.svelte" in ref_paths
assert "src/routes/(sverdle)/words.server.ts" in ref_paths, sorted(ref_paths)
assert "src/lib/game.ts" in ref_paths, sorted(ref_paths)
assert "src/routes/(sverdle)/+page.svelte" in ref_paths, sorted(ref_paths)
@pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True)
def test_references_from_typescript_file(self, language_server: SolidLanguageServer) -> None:
refs = language_server.request_references(os.path.join("src", "lib", "game.ts"), 3, 13)
ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs}
assert "src/routes/(sverdle)/+page.server.ts" in ref_paths
assert "src/routes/(sverdle)/+page.server.ts" in ref_paths, sorted(ref_paths)
@@ -0,0 +1,269 @@
"""Regression tests for the TypeScript/Svelte timeout policy split.
The base TypeScript server keeps its historical permissive behavior on readiness and
indexing timeouts (log and proceed), while the Svelte companion TS server is strict
(raise instead of serving requests from a cold or partially indexed program). These
tests pin that policy and the settings plumbing directly, without spawning language
server processes, so a future refactor cannot silently revert either side.
"""
import threading
import time
import pytest
from solidlsp.language_servers.svelte_language_server import (
SvelteCompanionPreparationError,
SvelteLanguageServer,
SvelteTypeScriptServer,
)
from solidlsp.language_servers.typescript_language_server import TypeScriptLanguageServer
from solidlsp.settings import SolidLSPSettings
def _bare_ts_server(cls: type[TypeScriptLanguageServer], custom_settings: dict | None = None) -> TypeScriptLanguageServer:
"""Create an instance without running __init__ (no process, no repo scan), setting only the
state the timeout machinery touches; same technique as test_rename_didopen.py.
"""
server = object.__new__(cls)
server.server_ready = threading.Event()
server._progress_lock = threading.Lock()
server._active_progress_tokens = set()
server._indexing_complete = threading.Event()
server._indexing_complete.set() # mirrors __init__: initially no active work
server._custom_settings = SolidLSPSettings.CustomLSSettings(custom_settings)
return server
class TestBaseTypeScriptTimeoutPolicy:
"""The base server must stay permissive: plain TypeScript setups are unchanged."""
def test_server_ready_timeout_proceeds(self) -> None:
server = _bare_ts_server(TypeScriptLanguageServer)
assert not server.server_ready.is_set()
server._handle_server_ready_timeout(10.0) # must not raise
assert server.server_ready.is_set()
def test_indexing_timeout_proceeds(self) -> None:
server = _bare_ts_server(TypeScriptLanguageServer)
server.expect_indexing()
server._active_progress_tokens.add("indexing-1")
server._handle_project_indexing_timeout(30.0) # must not raise
def test_timeout_defaults(self) -> None:
server = _bare_ts_server(TypeScriptLanguageServer)
assert server._get_server_ready_timeout() == 10.0
assert server._get_indexing_timeout() == 30.0
def test_timeouts_configurable_via_ls_specific_settings(self) -> None:
server = _bare_ts_server(TypeScriptLanguageServer, {"server_ready_timeout": 1.5, "indexing_timeout": 2.5})
assert server._get_server_ready_timeout() == 1.5
assert server._get_indexing_timeout() == 2.5
class TestSvelteCompanionTimeoutPolicy:
"""The Svelte companion must be strict: raise instead of serving from a cold/partial program."""
def test_server_ready_timeout_raises(self) -> None:
server = _bare_ts_server(SvelteTypeScriptServer)
with pytest.raises(TimeoutError, match="did not become ready within 30s"):
server._handle_server_ready_timeout(30.0)
assert not server.server_ready.is_set() # a strict server must not fake readiness
def test_indexing_timeout_raises_with_diagnostic_state(self) -> None:
server = _bare_ts_server(SvelteTypeScriptServer)
server.expect_indexing()
server._active_progress_tokens.add("initializing-js-ts-features")
with pytest.raises(TimeoutError) as exc_info:
server._handle_project_indexing_timeout(120.0)
message = str(exc_info.value)
assert "did not complete within 120s" in message
assert "complete=False" in message
assert "initializing-js-ts-features" in message
def test_companion_timeout_defaults_are_raised(self) -> None:
server = _bare_ts_server(SvelteTypeScriptServer)
assert server._get_server_ready_timeout() == 30.0
assert server._get_indexing_timeout() == 120.0
class TestWaitForIndexingStartOrCompletion:
def test_returns_false_when_active_progress_never_completes(self) -> None:
server = _bare_ts_server(TypeScriptLanguageServer)
server.expect_indexing()
server._active_progress_tokens.add("stuck-progress")
assert server._wait_for_indexing_start_or_completion(timeout=0.1, start_grace=0.0) is False
def test_treats_absent_progress_as_ready(self) -> None:
server = _bare_ts_server(TypeScriptLanguageServer)
server.expect_indexing()
assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=0.0) is True
assert server._indexing_complete.is_set()
def test_returns_immediately_when_no_indexing_expected(self) -> None:
# _indexing_complete is initially set; a long grace must not block in that case
server = _bare_ts_server(TypeScriptLanguageServer)
start = time.monotonic()
assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=30.0) is True
# must return via the early is_set() check, not sit out the 30s grace; the path is
# pure in-memory, so a 10s bound cannot flake on a slow runner
assert time.monotonic() - start < 10.0
def test_returns_true_when_progress_completes(self) -> None:
server = _bare_ts_server(TypeScriptLanguageServer)
server.expect_indexing()
server._active_progress_tokens.add("indexing-1")
def finish_indexing() -> None:
with server._progress_lock:
server._active_progress_tokens.clear()
server._indexing_complete.set()
timer = threading.Timer(0.05, finish_indexing)
timer.start()
try:
# both timer orderings pass: an early fire takes the absent-progress branch,
# which sets the event itself, while a late fire wakes Event.wait; ordering
# cannot flake this test. It fails only if the spawned thread does not run
# within the 30s ceiling, which means the runner is hung.
assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=0.0) is True
finally:
timer.cancel()
class _FakeSolidLSPSettings:
"""Minimal settings stand-in: returns a fixed TypeScript indexing_timeout so the guard tests can
prove the svelte-key value takes precedence over the typescript-key value.
"""
def __init__(self, ts_indexing_timeout: float) -> None:
self._ts_indexing_timeout = ts_indexing_timeout
def get_ls_specific_settings(self, language: object) -> "SolidLSPSettings.CustomLSSettings":
return SolidLSPSettings.CustomLSSettings({"indexing_timeout": self._ts_indexing_timeout})
class _FakeCompanionTSServer:
"""Hand-written stand-in for the companion TS server that records calls, so the guard tests assert
observable behavior (raised errors, message shape, which timeout is used) instead of mock interactions.
"""
class _FileBuffer:
def __init__(self, uri: str) -> None:
self.uri = uri
self.ref_count = 0
def __enter__(self) -> "_FakeCompanionTSServer._FileBuffer":
return self
def __exit__(self, *exc: object) -> bool:
return False
def __init__(self, *, wait_result: bool = True, open_error: Exception | None = None) -> None:
self.calls: list[str] = []
self.wait_timeout: float | None = None
self._wait_result = wait_result
self._open_error = open_error
def expect_indexing(self) -> None:
self.calls.append("expect_indexing")
def open_file(self, relative_path: str) -> "_FakeCompanionTSServer._FileBuffer":
self.calls.append("open_file")
if self._open_error is not None:
raise self._open_error
return self._FileBuffer(uri=f"file:///{relative_path}")
def _wait_for_indexing_start_or_completion(self, timeout: float) -> bool:
self.calls.append("wait")
self.wait_timeout = timeout
return self._wait_result
def describe_indexing_state(self) -> str:
return "complete=False, active_progress_tokens=stuck-progress"
class TestSvelteLanguageServerCompanionGuard:
"""SvelteLanguageServer._ensure_svelte_files_indexed_on_ts_server must fail loudly, not degrade."""
def _bare_svelte_ls(self, ts_server: _FakeCompanionTSServer, repo_path: str) -> SvelteLanguageServer:
ls = object.__new__(SvelteLanguageServer)
ls.repo_path = repo_path
ls._svelte_files_indexed = False
ls._indexed_svelte_file_uris = []
ls._custom_settings = SolidLSPSettings.CustomLSSettings({"indexing_timeout": 0.01})
# conflicting typescript-key value: the timeout assertions below prove the svelte key wins
ls._solidlsp_settings = _FakeSolidLSPSettings(ts_indexing_timeout=999.0)
ls._ts_server = ts_server
return ls
def test_raises_when_companion_indexing_times_out(self, tmp_path) -> None:
(tmp_path / "App.svelte").touch()
ts_server = _FakeCompanionTSServer(wait_result=False)
ls = self._bare_svelte_ls(ts_server, str(tmp_path))
with pytest.raises(TimeoutError, match="did not finish indexing 1 .svelte files"):
ls._ensure_svelte_files_indexed_on_ts_server()
# the svelte-key indexing_timeout (0.01), not the typescript-key (999.0), must reach the wait
assert ts_server.wait_timeout == 0.01
def test_succeeds_and_arms_progress_tracking_before_opening_files(self, tmp_path) -> None:
(tmp_path / "App.svelte").touch()
(tmp_path / "node_modules").mkdir()
(tmp_path / "node_modules" / "skip.svelte").touch()
(tmp_path / ".hidden").mkdir()
(tmp_path / ".hidden" / "skip.svelte").touch()
ts_server = _FakeCompanionTSServer(wait_result=True)
ls = self._bare_svelte_ls(ts_server, str(tmp_path))
ls._ensure_svelte_files_indexed_on_ts_server()
assert ts_server.calls.count("open_file") == 1
# a second call is a no-op — the observable consequence of the indexed flag
calls_after_indexing = list(ts_server.calls)
ls._ensure_svelte_files_indexed_on_ts_server()
assert ts_server.calls == calls_after_indexing
# expect_indexing must be armed BEFORE any file is opened, or early progress is lost to a race
assert ts_server.calls[0] == "expect_indexing"
assert ts_server.calls.index("expect_indexing") < ts_server.calls.index("open_file")
def test_collects_failed_opens_and_raises_preparation_error(self, tmp_path) -> None:
(tmp_path / "B.svelte").touch()
(tmp_path / "A.svelte").touch()
ts_server = _FakeCompanionTSServer(open_error=RuntimeError("didOpen failed"))
ls = self._bare_svelte_ls(ts_server, str(tmp_path))
with pytest.raises(SvelteCompanionPreparationError, match="2 Svelte file") as exc_info:
ls._ensure_svelte_files_indexed_on_ts_server()
assert "A.svelte, B.svelte" in str(exc_info.value)
# the first underlying error must be chained so the cause survives without DEBUG logs
assert isinstance(exc_info.value.__cause__, RuntimeError)
# a failed preparation must short-circuit before the indexing wait
assert "wait" not in ts_server.calls
def test_preparation_error_caps_file_listing(self, tmp_path) -> None:
for i in reversed(range(15)):
(tmp_path / f"{i:02d}.svelte").touch()
ts_server = _FakeCompanionTSServer(open_error=RuntimeError("didOpen failed"))
ls = self._bare_svelte_ls(ts_server, str(tmp_path))
with pytest.raises(SvelteCompanionPreparationError) as exc_info:
ls._ensure_svelte_files_indexed_on_ts_server()
message = str(exc_info.value)
assert "15 Svelte file(s)" in message
assert "09.svelte" in message # the 10th entry of the sorted listing is still shown
assert "10.svelte" not in message # the 11th is capped
assert "and 5 more" in message