Feat: LSP Server support hot reload (#5953)

* feat(core): Add LSP server config hot-reload support

- Implement reconcileServerConfigs to diff desired vs current LSP configs and apply minimal add/remove/restart operations with a serialized reconcile queue
- Add configHash utility to detect config changes via stable hashing
- Add lspConfigWatcher in CLI to watch .lsp.json and trigger reconciliation on file changes
- Extend LspServerManager with per-server config hash tracking and detailed debug logging
- Add design docs for LSP runtime reinitialization and hot-reload overview
- Include comprehensive unit tests for all new modules

* refactor(cli): Extract registerLspHotReload from main function

Move the LSP config file watcher setup and reconciliation logic into a dedicated module-private function registerLspHotReload, reducing the size and nesting depth of the main startup flow. Added a JSDoc summarizing responsibilities, early-return conditions, and the AppEvent.LspStatusChanged side effect.

* fix(lsp): release server resources during reload

* fix(lsp): address hot reload review feedback

* fix(lsp): harden hot reload reconciliation

* docs(lsp): update hot reload design notes

* fix(lsp): harden hot reload retry semantics

* fix(lsp): harden hot reload lifecycle

* fix(lsp): harden hot reload lifecycle

* fix(lsp): isolate hot reload recovery paths

* fix(lsp): align command probes and replay tracking

* fix(lsp): prevent crash restarts during shutdown

* fix(lsp): preserve reload state across failures

* fix(lsp): cancel reloads during shutdown

* fix(lsp): handle socket startup races

* fix(lsp): harden command probe env and socket startup

* fix(lsp): report skipped reload and restart states

* fix(lsp): harden hot reload lifecycle cleanup

* chore: add one comment for `Object.create(null)`

---------

Co-authored-by: heyang.why <heyang.why@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
Heyang Wang 2026-07-05 21:50:00 +08:00 committed by GitHub
parent 802c382ce1
commit 3bf0fa0af0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 5746 additions and 86 deletions

View file

@ -0,0 +1,73 @@
# Hot Reload Overall Plan
This directory tracks the design work for issue
[#3696](https://github.com/QwenLM/qwen-code/issues/3696): a comprehensive
hot-reload system for skills, extensions, MCP servers, LSP servers, and runtime
configuration.
## Goal
Users should be able to update skills, extension state, MCP/LSP configuration,
and supported settings without restarting the current Qwen Code session. The
system should preserve conversation context while making runtime state changes
predictable and visible.
## Sub-task Breakdown
The hot-reload plan has **6 top-level sub-tasks**. The current tracking issue
splits sub-task 3 into **3a** and **3b** for implementation clarity, so the
execution checklist contains **7 entries**.
| Task | Scope | Status | Design document |
| ---- | ---------------------------------------- | ------------------------ | -------------------------------------------------------------------- |
| 1 | Settings file change detection | Done in #4933 | [settings-change-detection.md](./settings-change-detection.md) |
| 2 | Skill hot-reload improvements | Done via #2415 and #3923 | Not in this directory |
| 3a | MCP server runtime re-initialization | In progress via #5561 | [mcp-runtime-reinitialization.md](./mcp-runtime-reinitialization.md) |
| 3b | LSP server runtime re-initialization | In progress | [lsp-runtime-reinitialization.md](./lsp-runtime-reinitialization.md) |
| 4 | Unified refresh/cache orchestration | Not started | Pending |
| 5 | User-facing `/reload` slash command | Not started | Pending |
| 6 | `needsRefresh` app-state/UI notification | Not started | Pending |
## Document Mapping
- `settings-change-detection.md` corresponds to **sub-task 1: Settings file
change detection**. It provides the watcher infrastructure: detect supported
`settings.json` changes, reload settings from disk, and notify listeners. It
intentionally does not push updated values into `Config` snapshots or restart
runtime subsystems.
- `mcp-runtime-reinitialization.md` corresponds to **sub-task 3a: MCP server
runtime re-initialization**. It consumes settings change events, updates the
runtime MCP configuration, and incrementally reconciles live MCP connections.
The original issue grouped MCP and LSP under top-level sub-task 3; this
document covers the MCP half only.
- `lsp-runtime-reinitialization.md` corresponds to **sub-task 3b: LSP server
runtime re-initialization**. It watches workspace `.lsp.json` changes,
reuses the existing native LSP client, and incrementally reconciles live LSP
servers.
## Implementation Order
1. Keep sub-task 1 as the foundation: settings changes are detected and
dispatched, but consumers decide what to refresh.
2. Complete sub-task 3a so MCP server additions, removals, and configuration
edits can take effect at runtime.
3. Add sub-task 3b for LSP runtime re-initialization using the same principle:
update runtime configuration, stop affected servers, and restart only what
changed.
4. Introduce sub-task 4 as the shared orchestration layer for cache and runtime
refreshes across skills, commands, prompts, extensions, MCP, and LSP.
5. Add sub-task 5 as the manual user entry point: `/reload` should call the
unified orchestration path and report what changed.
6. Add sub-task 6 for background-change UX: set `needsRefresh` when a detected
change cannot or should not be fully applied automatically, then prompt the
user to run `/reload`.
## Design Principle
Keep each layer narrow:
- file watching detects and reports settings changes;
- subsystem reinitialization updates only the affected runtime state;
- unified orchestration sequences existing refresh operations;
- UI commands and notifications expose the behavior without duplicating reload
logic.

View file

@ -0,0 +1,658 @@
# LSP Runtime Hot Reload Design
## Background
This design follows the same layering used by
`mcp-runtime-reinitialization.md`: the CLI decides when to trigger reloads, and
Core decides how to update runtime state. It also reuses the watcher principles
from `settings-change-detection.md`: no filesystem side effects at startup,
debounced changes, semantic diffs, serialized listeners, and listener failures
that do not affect the main session.
The key difference between LSP and MCP is that LSP server configuration does not
live in `settings.json`. Today the native LSP service uses `LspConfigLoader` to
read the workspace `.lsp.json` and enabled extensions' `lspServers`
declarations, writes the result into the single-session `LspServerManager` via
`NativeLspService.discoverAndPrepare()`, and finally starts all configured
servers with `start()`. Therefore `SettingsWatcher` alone cannot detect changes
to the workspace `.lsp.json`.
## Current Code Assessment
- LSP startup is controlled only by `--experimental-lsp` in
`packages/cli/src/config/config.ts`. There is currently no
`--allowed-lsp-server-names` flag or equivalent LSP CLI allow-list parameter;
the existing `--allowed-mcp-server-names` flag is MCP-only.
- `NativeLspService` is constructed once during CLI config loading. The startup
path calls `discoverAndPrepare()`, then `start()`, then wraps the service in
`NativeLspClient` and attaches it to `Config`.
- `Config.setLspClient()` and `Config.setLspInitializationError()` currently
throw after initialization, so runtime hot reload should not replace the
client object. It should keep the existing `NativeLspClient` and only
incrementally reconcile the service behind it.
- `LspConfigLoader` only reads the workspace `.lsp.json` and active extensions'
`lspServers`. The workspace `.lsp.json` overrides extension config by server
name.
- `LspServerManager.setServerConfigs()` currently clears all handles; it does
not yet support incremental reconcile.
- The current repository has no shared pool path for LSP. Each session owns its
own `NativeLspService` and subprocess/socket connections. The design should
leave a boundary for a future shared pool, but v1 only implements
single-session mode.
## Goals
Make LSP server configuration changes take effect without restarting the
current Qwen Code session:
- start a server when it is added;
- stop a server when it is removed, and remove it from status and tool routing;
- restart only the changed server when its config changes;
- keep unchanged servers connected and preserve their warm-up state;
- never start servers that are untrusted or not allowed;
- let LSP tools and `/lsp` status observe the new runtime state through the
existing client object.
## Non-goals
- Do not add a shared LSP process pool in this change.
- Do not support toggling `--experimental-lsp` at runtime. If LSP was not
enabled at startup, there is no service to reload.
- Do not fully watch extension install/uninstall changes that affect
`lspServers`; manual `/reload` will cover extension config changes.
## Design
### 1. Identify Each LSP Server With a Stable Hash
Add a small helper near the LSP config code:
```ts
export function lspServerConfigHash(config: LspServerConfig): string;
```
The hash must be stable and based on the normalized runtime config produced by
`LspConfigLoader`:
- `name`
- `languages`
- `transport`
- `command`
- `args`
- `env`
- `initializationOptions`
- `settings`
- `extensionToLanguage`
- `workspaceFolder`
- `rootUri`
- `startupTimeout`
- `shutdownTimeout`
- `restartOnCrash`
- `maxRestarts`
- `trustRequired`
- `socket`
Object keys must be sorted so JSON property order does not cause unnecessary
restarts. Array order stays significant because command argument order and
language priority can be meaningful. Do not include runtime fields such as
process id, status, restart count, diagnostics, or warm-up state.
For future shared-pool compatibility, define the pool identity as:
```text
lsp:<workspaceRoot>:<serverName>:<configHash>
```
The v1 single-session manager only needs to maintain `serverName -> configHash`,
but the same hash can later be reused directly in the pool key.
### 2. Add Incremental Reconcile to `LspServerManager`
Hot reload should not reuse `setServerConfigs()`, which clears every handle.
Add:
```ts
async reconcileServerConfigs(
configs: LspServerConfig[],
): Promise<LspReconcileResult>
```
Flow:
1. Build desired maps: `name -> config` and `name -> hash`.
2. For existing handles whose server no longer exists, call the existing
`stopServer()`, then delete the handle.
3. For existing handles whose hash changed, call `stopServer()`, replace the
handle with `{ config, status: 'NOT_STARTED' }`, then start it.
4. For new servers, create `{ config, status: 'NOT_STARTED' }` and start them.
5. For servers whose hash did not change, do nothing and keep the existing
handle.
Add a private field:
```ts
private serverConfigHashes = new Map<string, string>();
```
Clear it in `stopAll()` and `clearServerHandles()`.
Return:
```ts
interface LspReconcileResult {
added: string[];
removed: string[];
restarted: string[];
unchanged: string[];
failed: string[];
}
```
`skipped` is not part of the `LspServerManager` result. The manager only handles
configs that have passed admission; servers rejected by admission are aggregated
into the service-level result by `NativeLspService.reinitialize()`.
Concurrency:
- Add a reconcile queue in either `LspServerManager` or `NativeLspService` so
reconciles run serially. Stopping and starting the same process must not race.
- If a new config arrives while a server is still starting, wait for
`handle.startingPromise` before stopping it. Reuse the existing startup lock
instead of adding an extra per-server lock.
- `stopServer()` itself must await `handle.startingPromise` after setting
`stopRequested`, so `stopAll()`, remove, and restart paths all cover crash
restarts that are still assigning their connection/process.
Failure behavior:
- If a newly added or changed server fails to start, keep the handle and mark it
as `FAILED` so `/lsp` can explain the failure.
- Do not count a failed start as `added` or `restarted`; report it in
`failed`.
- Do not cache the config hash for a failed start. A later save with the same
config must retry instead of being classified as `unchanged`.
- If startup fails after a connection or process has been created, release that
connection/process before returning. Failed initialization must not leave a
language server process or socket connection alive behind a `FAILED` handle.
- If startup fails before connection creation, including trust rejection,
unsafe command path, or missing command, clear the cached config hash. A later
reconcile with the same config must retry instead of treating the failed
handle as unchanged.
- If a removed server logs an error during shutdown, still delete it from the
handle map.
- One server's startup failure must not block reconcile for other servers.
Resource cleanup:
- `stopServer()` must release both sides of an owned server: gracefully shut down
and end the LSP connection, then kill the spawned process if it is still
alive. This matters for `tcp`/`socket` transports that were launched with a
`command`; closing the socket alone is not enough.
- `process.kill()` must be isolated with its own error handling. A process that
exits during cleanup must not abort the rest of reconcile.
- Graceful shutdown must always have a bounded wait. If the server config does
not specify `shutdownTimeout`, use the default shutdown timeout instead of
awaiting `connection.shutdown()` forever.
- Shutdown timeout timers must be cleared when shutdown completes or fails so a
large timeout does not retain the handle longer than necessary.
- The underlying `shutdown()` promise must be observed even when the timeout
wins the race, so a late server-side rejection cannot surface as an
unhandled rejection.
- `stopAll()` must participate in the same reconcile queue as hot reload. It is
not enough to wait for the current queue and then iterate handles, because a
new reconcile could otherwise enter between the wait and handle cleanup.
- `NativeLspService.stop()` must also cancel any in-flight or queued
`reinitialize()` operation before stopping servers. The implementation uses
cooperative cancellation with `AbortController`: stop marks the service as
stopping, aborts the active reload, and every queued reload checks the signal
before loading config, reconciling, clearing document tracking, replaying
documents, or waiting on replay delays. This prevents shutdown from blocking
indefinitely on a slow reload while also preventing a cancelled reload from
starting new LSP processes after `stopAll()`.
- Crash restarts must also serialize through the reconcile queue, or clear the
hash when they permanently fail. They must not start a replacement process in
parallel with a config-change reconcile.
- Crash restart reset must isolate `connection.end()` and `process.kill()`
errors. Reset runs when the old connection/process may already be broken, and
cleanup failures must not prevent the queued restart from continuing.
- `NativeLspService.stop()` must clear `openedDocuments` and `lastConnections`
after `serverManager.stopAll()` so a stopped service does not retain old
document sets or connection objects.
### 3. Add `NativeLspService.reinitialize()`
Add:
```ts
async reinitialize(): Promise<LspServiceReinitializeResult>
```
Flow:
1. If `requireTrustedWorkspace` is true and `!config.isTrustedFolder()`, call
`serverManager.stopAll()` and return. This prevents old LSP processes from
continuing after the workspace becomes untrusted.
2. Use the existing `LspConfigLoader` to load the workspace `.lsp.json` and
extension configs.
3. Merge configs using the current precedence.
4. Apply the LSP admission filter before reconcile.
5. Call `serverManager.reconcileServerConfigs(serverConfigs)`.
6. Clear `openedDocuments` and `lastConnections` only for removed and
successfully restarted servers; preserve document state for unchanged and
failed servers. Failed servers keep their document tracking so a later
successful restart can replay the same open documents.
7. For successfully restarted servers, replay `textDocument/didOpen` for
documents that were open before the restart. This gives the replacement
server the same document context without waiting for the next hover,
completion, or diagnostic request to lazily reopen each file. After replaying
one or more documents for a server, wait for the same document-open delay
used by lazy `ensureDocumentOpen()` before reporting reload completion.
The open-document snapshot must be taken after `reconcileServerConfigs()`
returns and must be scoped to `reconcile.restarted`. Documents opened while
reconcile is pending are then included in the replay snapshot before tracking is
cleared for restarted servers.
Initial discovery should use the same admission filter before calling
`setServerConfigs()`. This keeps startup and hot reload status consistent for
per-server `trustRequired` filtering in untrusted workspaces.
`.lsp.json` parse failures need special handling: do not treat parse failure as
empty config. The watcher should report an invalid-config event so the CLI can
show a user-visible error, but it must not call `reinitialize()` for that event.
`reinitialize()` should preserve the old runtime state, skip reconcile, and
write the error to status/logs. Only deleting the file, or parsing a valid empty
JSON config, means the desired config is empty.
Cold startup and hot reload intentionally use different user-config parsing
strictness:
- `loadUserConfigs()` stays lenient for startup compatibility. It skips invalid
server entries and returns the valid entries that can be built.
- `loadUserConfigsStrict()` is used by hot reload. If the existing `.lsp.json`
is syntactically valid but contains an invalid top-level shape or a server
entry that cannot be built, it returns an error and `reinitialize()` does not
reconcile. This preserves the currently running LSP state for invalid edits.
The strict path must not introduce field-level validation that cold startup
does not also enforce, because that would make a config valid at startup but
invalid on the next save. Tightening known-field validation should be handled
as a separate compatibility decision for both startup and hot reload. If the
file is missing or is deleted during the strict load, treat that `ENOENT` as a
valid empty user config, because deleting `.lsp.json` is the explicit way to
remove all workspace user LSP servers.
`NativeLspService.reinitialize()` returns a service-level result:
```ts
interface LspServiceReinitializeResult {
reconcile: LspReconcileResult;
skipped: Array<{
name: string;
reason: 'server_trust_required';
}>;
}
```
Add an optional `reinitialize()` method to `NativeLspClient` and delegate to the
service. To avoid opaque type assertions in `Config.reinitializeLsp()`, extend
the `LspClient` interface directly:
```ts
reinitialize?: () => Promise<LspServiceReinitializeResult>;
```
Add to `Config`:
```ts
async reinitializeLsp(): Promise<LspServiceReinitializeResult | undefined>
```
When LSP is disabled or no client exists, this is a no-op. This method must not
replace the client after `Config.initialize()`.
Because `setLspInitializationError()` currently rejects calls after
initialization, add a runtime-safe private state setter:
```ts
private setRuntimeLspInitializationError(error: Error | string | undefined): void
```
`reinitializeLsp()` uses it to expose reload failures through
`getLspStatusSnapshot()` without loosening the public post-init client mutation
API. A returned reconcile result with `failed` servers is a partial failure, not
a clean success. `reinitializeLsp()` must set `initializationError` for that
case and only clear the error when the reload has no failed servers.
### 4. Admission and Permission Boundary
Current LSP safety checks include:
- `--experimental-lsp` is the only enablement switch;
- workspace trust is checked before discovery/startup;
- each server's `trustRequired` defaults to true;
- command existence and command path safety are checked before spawn;
- `workspaceFolder` is constrained to the workspace root.
Hot reload must preserve these checks and complete them before starting a new
server or restarting a changed server. The key rule is: do not spawn first and
decide whether the server is allowed later.
Workspace `.lsp.json` is workspace-controlled input. User configs must
therefore always be treated as `trustRequired: true`, even if the file
explicitly declares `"trustRequired": false`. Extension-provided LSP configs may
still use their declared `trustRequired` value. This prevents an untrusted
workspace from lowering its own trust boundary.
Environment variables from `.lsp.json` are also workspace-controlled. Runtime
spawn may merge allowed env overrides, but code-injection variables such as
`NODE_OPTIONS`, `LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_INSERT_LIBRARIES`, and
`DYLD_LIBRARY_PATH` must not be overridden by LSP config. `PATH` is allowed for
the actual server process to preserve common toolchain setups. Command
existence probing may keep regular config-provided env values that probes may
need, but it must not use config-provided `PATH` when resolving bare command
names. This prevents a malicious workspace `PATH` from redirecting a probe such
as `clangd --version` to an unintended executable before the real startup path.
Sensitive env key filtering, including the probe-only `PATH` filter, must be
case-insensitive so Windows-style case-insensitive environment names such as
`Path`, `node_options`, or `Ld_PreLoad` cannot bypass the denylist.
Allow-list boundary:
- The current repository does not support a CLI allow-list for LSP server names.
I confirmed LSP only has `--experimental-lsp`; allow-list parameters are
MCP-only.
- If this feature adds `--allowed-lsp-server-names`, it must behave like the MCP
startup allow-list and act as an upper bound for the entire session lifetime.
Runtime config may narrow this set, but it must not expand beyond the CLI
startup upper bound.
- Store the startup upper bound in `ConfigParameters.lsp`:
```ts
cliAllowedLspServerNames?: string[];
```
Expose a getter for it. Do not read the upper bound from mutable settings.
Admission should be extracted into a pure function:
```ts
filterLspServerConfigs(configs, {
workspaceTrusted,
requireTrustedWorkspace,
cliAllowedServerNames,
}): {
admitted: LspServerConfig[];
skipped: Array<{
name: string;
reason: 'server_trust_required';
}>;
}
```
Even though there is no LSP approval store or CLI allow-list today, this helper
makes the security boundary explicit and leaves room for future hash-based
approval gates. If a future `--allowed-lsp-server-names` flag is added, it
should add a `not_allowed` skipped reason at that time instead of carrying an
unwired allow-list path in v1.
Trust semantics must match the current startup path:
- If `requireTrustedWorkspace` is true and the workspace is untrusted,
`NativeLspService.reinitialize()` stops all servers at the service layer and
returns. It does not enter the admission filter and does not preserve old
servers.
- If `requireTrustedWorkspace` is false, the service does not short-circuit
globally, but the admission filter still skips individual servers with
`trustRequired: true`.
- If the workspace is trusted, `trustRequired` does not block the server.
### 5. Triggering
Two trigger paths are needed.
#### Automatic Workspace `.lsp.json` Trigger
Add a narrow `LspConfigWatcher` in the CLI, modeled after `SettingsWatcher` but
with a smaller responsibility:
- watch only the workspace root and strictly match basename `.lsp.json`;
- do not create any directory or file;
- debounce for 300 ms;
- compare `.lsp.json` before/after using parse + canonicalize so formatting-only
changes do not trigger reload;
- treat `ENOENT` as deletion;
- distinguish JSON parse failures from other read failures. Both should notify
the listener with a user-visible invalid-config event and preserve the old
runtime state, but the error message must reflect whether the file was invalid
JSON or unreadable;
- file deletion is a separate event and should notify the reload listener,
producing empty workspace config;
- run callbacks serially;
- use listener timeout and failure isolation matching `SettingsWatcher`;
- advance the stored semantic snapshot only after listener notification succeeds.
If the listener throws or times out, retain the previous snapshot so saving the
same content again retries the reload.
Register the watcher only when `config.isLspEnabled()` and the client supports
`reinitialize()`. On change, call:
```ts
await config.reinitializeLsp();
```
Then emit an explicit runtime event such as `AppEvent.LspStatusChanged`.
UI surfaces such as `/lsp`, `/about`, or `/status` can subscribe to that event
to refresh. If reconcile returns partial failures, emit the status-changed event
before throwing back to the watcher; this lets the UI observe successfully
restarted servers while the watcher still retains the old semantic snapshot for
retry. On failure, also show a user-visible error through `AppEvent.LogError`;
include the underlying parser/startup error message when available, and do not
only write a debug log.
#### Manual `/reload` Trigger
When the future `/reload` command lands, it should call both:
```ts
await config.reinitializeMcpServers(...);
await config.reinitializeLsp();
```
Manual reload also provides the fallback path for extension `lspServers`
changes, because those changes may not map to a workspace `.lsp.json` file
event.
## Single Session and Shared Pool
Current state: only single-session mode exists. The repository has no LSP
equivalent of the MCP transport pool.
v1: implement incremental reconcile inside `LspServerManager`. Each session owns
its own process and socket.
Future shared pool: keep `NativeLspService` as the consumer and replace
`LspServerManager` internals with acquire/release of:
```text
lsp:<workspaceRoot>:<name>:<hash>
```
pool entries. Admission filtering must still happen before acquire, matching the
MCP shared-pool fix, so disallowed or untrusted servers cannot be started
through the pool path.
## Unit Test Plan
Prioritize unit tests. Integration tests against real LSP servers are slow and
environment-dependent, so they are not required.
### Core Tests
`packages/core/src/lsp/configHash.test.ts`
- hash ignores object key order;
- changes to command, args order, env, settings, workspace folder, socket, and
trust requirement change the hash;
- hash excludes status/process/runtime fields.
`packages/core/src/lsp/LspServerManager.test.ts`
- adding a server starts it exactly once;
- removing a server shuts it down and deletes it from handles;
- hash changes stop the old handle and start a new handle;
- unchanged hash does not stop/start and preserves handle identity;
- startup failure after connection creation releases the connection and owned
process;
- stopping a `tcp`/`socket` server launched by `command` closes the connection
and kills the owned process;
- shutdown timeout timers are cleared when shutdown completes first;
- missing `shutdownTimeout` still uses the default shutdown timeout and cannot
block reconcile forever;
- `stopAll()` waits for in-flight startup before releasing resources;
- `stopAll()` is serialized through the reconcile queue and cannot run
concurrently with a later reconcile;
- `process.kill()` errors are logged and do not abort cleanup;
- one server startup failure does not affect another server's reconcile;
- concurrent reconciles run serially;
- `stopAll()` and `clearServerHandles()` clear the hash map;
- failed starts are reported in `failed`, are not reported as added/restarted,
and do not cache their config hash;
- initial startup failures clear the cached hash so a later reconcile with the
same config retries;
- crash restarts serialize with reconcile and clear cached hashes on permanent
failure;
- crash restart reset ignores connection/process cleanup errors and continues
the queued restart;
- command existence probing keeps regular config-provided env values, but does
not use config-provided `PATH`, and code-injection env overrides are filtered
before spawn;
- reconcile return value contains added/removed/restarted/unchanged/failed, not
admission skipped.
Mock `createLspConnection`, initialization, and shutdown in tests. Do not start
real language servers.
`packages/core/src/lsp/NativeLspService.test.ts`
- `reinitialize()` loads workspace and extension config and passes merged config
to manager reconcile;
- `.lsp.json` parse failure preserves old runtime state and does not call
manager reconcile;
- strict hot reload rejects invalid top-level shapes and server entries that
cannot be built without reconciling, while cold startup keeps loading valid
entries from the same file;
- deleting `.lsp.json` treats workspace config as empty and triggers reconcile;
- strict loading treats `ENOENT` as an empty user config, including the
deletion race where the file disappears between watcher notification and
reload;
- untrusted workspace stops all servers and does not reconcile/start;
- initial discovery applies the same per-server `trustRequired` admission filter
as hot reload;
- workspace `.lsp.json` cannot opt out of `trustRequired`;
- if a CLI allow-list is implemented, the upper bound filters admitted configs;
- service-level return value aggregates admission skipped reasons;
- restarted/removed servers only clear their own document tracking.
- failed servers do not clear document tracking and can replay those documents
after a later successful restart.
- restarted servers replay `textDocument/didOpen` for previously opened
documents after the replacement server is ready, then wait for the
document-open processing delay.
- documents opened while reconcile is pending are included in the replay
snapshot for restarted servers.
- `stop()` cancels in-flight replay delay and queued `reinitialize()` calls
before they can start new servers.
- `stop()` clears document tracking caches after stopping all servers.
`packages/core/src/config/config.test.ts`
- `reinitializeLsp()` is a no-op when disabled or no client exists;
- when enabled and the client supports `reinitialize`, it delegates the call;
- when reinitialize throws, the status snapshot exposes the initialization/reload
error.
- when reinitialize returns partial failures, the status snapshot exposes an
initialization/reload error until a later fully successful reload clears it.
### CLI Tests
`packages/cli/src/config/lspConfigWatcher.test.ts`
- does not create `.lsp.json`;
- detects create/modify/delete;
- ignores unrelated files;
- ignores formatting-only changes after canonical parse;
- parse failure emits an invalid-config notification for user-visible feedback
and does not trigger LSP reinitialization;
- non-ENOENT read failure emits a user-visible read-failure message and does not
trigger LSP reinitialization;
- deleting `.lsp.json` triggers the reload listener;
- duplicate file events are debounced;
- slow listeners run serially;
- listener failure does not advance the stored snapshot and the same content can
be retried by a later notification.
`packages/cli/src/ui/AppContainer.test.tsx` or the corresponding event test
- `AppEvent.LspStatusChanged` triggers UI refresh;
- reload failure emits a user-visible error through `AppEvent.LogError`.
- partial reconcile failure still emits `AppEvent.LspStatusChanged` before the
listener rejects, so UI state can reflect successful parts of the reload.
`packages/cli/src/config/config.test.ts`
- preserve the existing assertion that `--experimental-lsp` constructs and
starts native LSP;
- if `--allowed-lsp-server-names` is added, the parser supports comma-separated
values and repeated flags, and stores them as the startup upper bound.
`packages/cli/src/ui/commands/lspCommand.test.ts`
- if `LspStatusSnapshot` exposes skipped reasons, status output can show
skipped/disallowed servers.
Coverage goals: new pure functions should be near 100%; watcher branch coverage
should be comparable to `SettingsWatcher`; manager reconcile must cover
add/remove/change/unchanged/failure/concurrency.
## Strict Review
### Conclusion
1. **v1 should not use stop-all/start-all.**
That implementation is simplest, but every save would restart unchanged
language servers and lose warm state. The current manager already has
per-server lifecycle methods, and incremental reconcile is a manageable
amount of additional code.
2. **Do not put `.lsp.json` changes into `SettingsWatcher`.**
`SettingsWatcher` is responsible for settings-scope reloads. Making it watch
arbitrary workspace files would blur the contract and make MCP/settings
behavior harder to reason about. A separate, narrow `.lsp.json` watcher is
clearer.
3. **Do not replace `NativeLspClient` after initialization.**
`Config.setLspClient()` explicitly forbids post-init mutation. Updating the
service behind the adapter avoids expanding the lifecycle API.
4. **Admission must happen before process spawn or pool acquire.**
This is the same risk called out in the MCP shared-pool design. Even though
LSP has no pool today, service-level reload results should return pre-start
filtering skipped reasons so a future pool path does not accidentally start a
rejected server.
5. **A new LSP CLI allow-list is optional, but if added it must be an upper
bound.**
The current code has no LSP allow-list. The design must not allow settings to
expand command-line restrictions at runtime, or it would be weaker than MCP
hot-reload security semantics.
### Remaining Risks
- Extension `lspServers` may change without `.lsp.json` changing. The automatic
watcher does not cover all extension filesystem changes; manual `/reload`
covers that path.
- Some language servers do not tolerate rapid restarts well. Serialized
reconcile and debounce reduce the risk, but tests should cover fast
consecutive changes.
- TCP/socket servers may be externally managed daemons. Reconcile should close
the connection, but it should only assume ownership of the process when this
process spawned the server via `command`.

View file

@ -0,0 +1,393 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { LspConfigWatcher } from './lsp-config-watcher.js';
// Keep chokidar fully in-process so watcher lifecycle tests can trigger file
// events deterministically without arming real filesystem watchers.
const chokidarMock = vi.hoisted(() => {
const handlers = new Map<string, (...args: unknown[]) => void>();
const watcher = {
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
handlers.set(event, handler);
return watcher;
}),
close: vi.fn(async () => {}),
};
return {
handlers,
watch: vi.fn(() => watcher),
watcher,
};
});
const debugLoggerMock = vi.hoisted(() => ({
info: vi.fn(),
warn: vi.fn(),
}));
vi.mock('chokidar', () => ({
watch: chokidarMock.watch,
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
...(await importOriginal<typeof import('@qwen-code/qwen-code-core')>()),
createDebugLogger: vi.fn(() => debugLoggerMock),
}));
// The watcher intentionally keeps its refresh pipeline private. These tests
// exercise it directly to cover semantic diffing and timeout behavior without
// waiting on real chokidar scheduling.
type TestableWatcher = {
listener?: (event: unknown) => void | Promise<void>;
handleChange(): Promise<void>;
notifyListener(event: unknown): Promise<boolean>;
};
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-watcher-'));
tempDirs.push(dir);
return dir;
}
describe('LspConfigWatcher', () => {
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
chokidarMock.handlers.clear();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('does not create .lsp.json during construction', () => {
const dir = makeTempDir();
new LspConfigWatcher(dir);
expect(fs.existsSync(path.join(dir, '.lsp.json'))).toBe(false);
});
it('notifies on create and suppresses formatting-only changes', async () => {
const dir = makeTempDir();
const watcher = new LspConfigWatcher(dir) as unknown as TestableWatcher;
const listener = vi.fn();
watcher.listener = listener;
fs.writeFileSync(
path.join(dir, '.lsp.json'),
'{"typescript":{"command":"tsserver"}}',
);
await watcher.handleChange();
expect(listener).toHaveBeenCalledWith({
path: path.join(dir, '.lsp.json'),
changeType: 'created',
});
expect(debugLoggerMock.info).toHaveBeenCalledWith(
`LSP config changed: created ${path.join(dir, '.lsp.json')}`,
);
fs.writeFileSync(
path.join(dir, '.lsp.json'),
JSON.stringify({ typescript: { command: 'tsserver' } }, null, 2),
);
await watcher.handleChange();
expect(listener).toHaveBeenCalledTimes(1);
});
// Invalid JSON should be visible to the user, but it must not replace the
// current runtime state. A later delete still needs to reconcile to empty.
it('notifies invalid config and notifies on later deletion', async () => {
const dir = makeTempDir();
const configPath = path.join(dir, '.lsp.json');
fs.writeFileSync(configPath, '{"typescript":{"command":"tsserver"}}');
const watcher = new LspConfigWatcher(dir) as unknown as TestableWatcher;
const listener = vi.fn();
watcher.listener = listener;
fs.writeFileSync(configPath, '{');
await watcher.handleChange();
expect(listener).toHaveBeenCalledWith({
path: configPath,
changeType: 'invalid',
error:
'Invalid JSON in .lsp.json; existing LSP runtime state is unchanged.',
});
fs.unlinkSync(configPath);
await watcher.handleChange();
expect(listener).toHaveBeenCalledWith({
path: configPath,
changeType: 'deleted',
});
});
it('reports read errors separately from invalid JSON', async () => {
const dir = makeTempDir();
const configPath = path.join(dir, '.lsp.json');
const watcher = new LspConfigWatcher(dir) as unknown as TestableWatcher;
const listener = vi.fn();
watcher.listener = listener;
fs.mkdirSync(configPath);
await watcher.handleChange();
expect(listener).toHaveBeenCalledWith({
path: configPath,
changeType: 'invalid',
error:
'Failed to read .lsp.json; existing LSP runtime state is unchanged.',
});
expect(debugLoggerMock.warn).toHaveBeenCalledWith(
'Failed to read .lsp.json:',
expect.objectContaining({ code: 'EISDIR' }),
);
});
it('retries the same config content when listener notification fails', async () => {
const dir = makeTempDir();
const configPath = path.join(dir, '.lsp.json');
const watcher = new LspConfigWatcher(dir) as unknown as TestableWatcher;
const listener = vi
.fn()
.mockRejectedValueOnce(new Error('reload failed'))
.mockResolvedValueOnce(undefined);
watcher.listener = listener;
fs.writeFileSync(configPath, '{"typescript":{"command":"tsserver"}}');
await watcher.handleChange();
await watcher.handleChange();
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenNthCalledWith(1, {
path: configPath,
changeType: 'created',
});
expect(listener).toHaveBeenNthCalledWith(2, {
path: configPath,
changeType: 'created',
});
});
it('debounces duplicate filesystem events', async () => {
vi.useFakeTimers();
const dir = makeTempDir();
const configPath = path.join(dir, '.lsp.json');
const watcher = new LspConfigWatcher(dir);
const listener = vi.fn();
watcher.startWatching(listener);
const onAll = chokidarMock.handlers.get('all');
expect(onAll).toBeDefined();
fs.writeFileSync(configPath, '{"typescript":{"command":"tsserver"}}');
onAll?.('add', configPath);
onAll?.('change', configPath);
await vi.advanceTimersByTimeAsync(LspConfigWatcher.DEBOUNCE_MS);
expect(listener).toHaveBeenCalledTimes(1);
await watcher.stopWatching();
});
it('allows retrying startWatching after watcher initialization fails', async () => {
const dir = makeTempDir();
const watcher = new LspConfigWatcher(dir);
const listener = vi.fn();
chokidarMock.watch.mockImplementationOnce(() => {
throw new Error('watch failed');
});
watcher.startWatching(listener);
watcher.startWatching(listener);
expect(chokidarMock.watch).toHaveBeenCalledTimes(2);
expect(debugLoggerMock.warn).toHaveBeenCalledWith(
`Failed to start LSP config watcher for ${dir}:`,
expect.any(Error),
);
await watcher.stopWatching();
expect(chokidarMock.watcher.close).toHaveBeenCalledOnce();
});
it('ignores filesystem events after stopWatching begins', async () => {
vi.useFakeTimers();
const dir = makeTempDir();
const configPath = path.join(dir, '.lsp.json');
let resolveClose: (() => void) | undefined;
chokidarMock.watcher.close.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveClose = resolve;
}),
);
const watcher = new LspConfigWatcher(dir);
const listener = vi.fn();
watcher.startWatching(listener);
const onAll = chokidarMock.handlers.get('all');
expect(onAll).toBeDefined();
const stopPromise = watcher.stopWatching();
onAll?.('change', configPath);
await vi.advanceTimersByTimeAsync(LspConfigWatcher.DEBOUNCE_MS);
expect(listener).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
resolveClose?.();
await stopPromise;
});
it('unrefs the debounce timer', async () => {
const dir = makeTempDir();
const configPath = path.join(dir, '.lsp.json');
const watcher = new LspConfigWatcher(dir);
watcher.startWatching(vi.fn());
const onAll = chokidarMock.handlers.get('all');
expect(onAll).toBeDefined();
onAll?.('change', configPath);
const refreshTimer = (
watcher as unknown as { refreshTimer: NodeJS.Timeout | null }
).refreshTimer;
expect(refreshTimer).toBeDefined();
expect(refreshTimer?.hasRef()).toBe(false);
await watcher.stopWatching();
});
it('waits for an active listener before stopping', async () => {
vi.useFakeTimers();
const dir = makeTempDir();
const configPath = path.join(dir, '.lsp.json');
let resolveListener: (() => void) | undefined;
const listener = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveListener = resolve;
}),
);
const watcher = new LspConfigWatcher(dir);
watcher.startWatching(listener);
const onAll = chokidarMock.handlers.get('all');
expect(onAll).toBeDefined();
fs.writeFileSync(configPath, '{"typescript":{"command":"tsserver"}}');
onAll?.('add', configPath);
await vi.advanceTimersByTimeAsync(LspConfigWatcher.DEBOUNCE_MS);
expect(listener).toHaveBeenCalledTimes(1);
let stopped = false;
const stopPromise = watcher.stopWatching().then(() => {
stopped = true;
});
await Promise.resolve();
expect(stopped).toBe(false);
expect(chokidarMock.watcher.close).not.toHaveBeenCalled();
resolveListener?.();
await stopPromise;
expect(stopped).toBe(true);
expect(chokidarMock.watcher.close).toHaveBeenCalledOnce();
});
it('does not replace the active drain with a no-op drain', async () => {
vi.useFakeTimers();
const dir = makeTempDir();
const configPath = path.join(dir, '.lsp.json');
let resolveListener: (() => void) | undefined;
const listener = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveListener = resolve;
}),
);
const watcher = new LspConfigWatcher(dir);
watcher.startWatching(listener);
const onAll = chokidarMock.handlers.get('all');
expect(onAll).toBeDefined();
fs.writeFileSync(configPath, '{"typescript":{"command":"tsserver"}}');
onAll?.('add', configPath);
await vi.advanceTimersByTimeAsync(LspConfigWatcher.DEBOUNCE_MS);
expect(listener).toHaveBeenCalledTimes(1);
fs.writeFileSync(configPath, '{"typescript":{"command":"pyright"}}');
onAll?.('change', configPath);
await vi.advanceTimersByTimeAsync(LspConfigWatcher.DEBOUNCE_MS);
let stopped = false;
const stopPromise = watcher.stopWatching().then(() => {
stopped = true;
});
await Promise.resolve();
expect(stopped).toBe(false);
resolveListener?.();
await stopPromise;
expect(stopped).toBe(true);
expect(chokidarMock.watcher.close).toHaveBeenCalledOnce();
});
// Listener timeout is the isolation boundary between file watching and the
// running CLI session: a hung reload callback should be logged and swallowed.
it('times out a hanging listener without throwing', async () => {
vi.useFakeTimers();
const dir = makeTempDir();
const watcher = new LspConfigWatcher(dir) as unknown as TestableWatcher;
watcher.listener = vi.fn(() => new Promise<void>(() => {}));
const notifyPromise = watcher.notifyListener({
path: path.join(dir, '.lsp.json'),
changeType: 'modified',
});
await vi.advanceTimersByTimeAsync(LspConfigWatcher.LISTENER_TIMEOUT_MS);
await expect(notifyPromise).resolves.toBe(false);
expect(debugLoggerMock.warn).toHaveBeenCalledWith(
'LSP config change listener error:',
expect.any(Error),
);
});
it('swallows listener rejection that arrives after timeout', async () => {
vi.useFakeTimers();
const dir = makeTempDir();
const watcher = new LspConfigWatcher(dir) as unknown as TestableWatcher;
let rejectListener: ((error: Error) => void) | undefined;
watcher.listener = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectListener = reject;
}),
);
const notifyPromise = watcher.notifyListener({
path: path.join(dir, '.lsp.json'),
changeType: 'modified',
});
await vi.advanceTimersByTimeAsync(LspConfigWatcher.LISTENER_TIMEOUT_MS);
await expect(notifyPromise).resolves.toBe(false);
rejectListener?.(new Error('late listener failure'));
await Promise.resolve();
expect(debugLoggerMock.warn).toHaveBeenCalledTimes(1);
expect(debugLoggerMock.warn).toHaveBeenCalledWith(
'LSP config change listener error:',
expect.any(Error),
);
});
});

View file

@ -0,0 +1,303 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { watch as watchFs, type FSWatcher } from 'chokidar';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
const debugLogger = createDebugLogger('LSP_CONFIG_WATCHER');
export type LspConfigChangeEvent =
| LspConfigRuntimeChangeEvent
| LspConfigInvalidEvent;
export interface LspConfigRuntimeChangeEvent {
path: string;
changeType: 'modified' | 'created' | 'deleted';
}
export interface LspConfigInvalidEvent {
path: string;
changeType: 'invalid';
/** User-facing message; invalid configs preserve the current LSP runtime. */
error: string;
}
export type LspConfigChangeListener = (
event: LspConfigChangeEvent,
) => void | Promise<void>;
interface LspConfigSnapshot {
exists: boolean;
/** Canonical JSON string. Undefined means the file exists but is invalid or unreadable. */
canonical?: string;
error?: string;
}
/**
* Watches the workspace `.lsp.json` and reports semantic config changes.
*
* This watcher is intentionally narrow: it never creates files, only considers
* the workspace-root `.lsp.json`, debounces noisy filesystem events, and
* serializes listener calls so LSP reloads cannot overlap.
*/
export class LspConfigWatcher {
private watcher?: FSWatcher;
private listener?: LspConfigChangeListener;
private refreshTimer: NodeJS.Timeout | null = null;
private activeDrain?: Promise<void>;
private processing = false;
private pending = false;
private started = false;
private lastSnapshot: LspConfigSnapshot;
private readonly configPath: string;
static readonly DEBOUNCE_MS = 300;
static readonly LISTENER_TIMEOUT_MS = 30_000;
constructor(private readonly workspaceRoot: string) {
this.configPath = path.join(workspaceRoot, '.lsp.json');
this.lastSnapshot = this.readSnapshot();
}
startWatching(listener: LspConfigChangeListener): void {
if (this.started) return;
debugLogger.info(`Starting LSP config watcher for ${this.configPath}`);
try {
const watcher = watchFs(this.workspaceRoot, {
ignoreInitial: true,
depth: 0,
})
.on('all', (_event: string, changedPath: string) => {
if (path.basename(changedPath) !== '.lsp.json') return;
this.scheduleRefresh();
})
.on('error', (error: unknown) => {
debugLogger.warn(
`LSP config watcher error for ${this.workspaceRoot}:`,
error,
);
});
this.watcher = watcher;
this.listener = listener;
this.started = true;
} catch (error) {
this.watcher = undefined;
this.listener = undefined;
this.started = false;
debugLogger.warn(
`Failed to start LSP config watcher for ${this.workspaceRoot}:`,
error,
);
}
}
async stopWatching(): Promise<void> {
if (!this.started) return;
this.started = false;
debugLogger.info(`Stopping LSP config watcher for ${this.configPath}`);
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.pending = false;
await this.activeDrain;
this.listener = undefined;
try {
await this.watcher?.close();
} catch (error) {
debugLogger.warn('LSP config watcher close error:', error);
}
this.watcher = undefined;
}
private scheduleRefresh(): void {
if (!this.started) return;
this.pending = true;
if (this.refreshTimer) clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(() => {
this.refreshTimer = null;
if (this.processing) return;
const activeDrain = this.drainPendingChange().catch((error) => {
debugLogger.warn('LSP config watcher refresh error:', error);
});
this.activeDrain = activeDrain;
void activeDrain.finally(() => {
if (this.activeDrain === activeDrain) {
this.activeDrain = undefined;
}
});
}, LspConfigWatcher.DEBOUNCE_MS);
if (
typeof this.refreshTimer === 'object' &&
this.refreshTimer !== null &&
'unref' in this.refreshTimer
) {
this.refreshTimer.unref();
}
}
/** Drains debounced changes one at a time while preserving a trailing update. */
private async drainPendingChange(): Promise<void> {
if (this.processing) return;
this.processing = true;
try {
while (this.pending) {
this.pending = false;
await this.handleChange();
}
} finally {
this.processing = false;
}
}
/**
* Compares the previous and current semantic snapshots.
*
* Invalid JSON emits an `invalid` event for user feedback but does not report
* a runtime config change; the caller must keep the existing LSP state.
*/
private async handleChange(): Promise<void> {
const before = this.lastSnapshot;
const after = this.readSnapshot();
if (after.exists && after.canonical === undefined) {
debugLogger.warn(
`Invalid .lsp.json; keeping existing LSP runtime state.`,
);
const notified = await this.notifyListener({
path: this.configPath,
changeType: 'invalid',
error:
after.error ??
'Invalid JSON in .lsp.json; existing LSP runtime state is unchanged.',
});
if (notified) {
this.lastSnapshot = after;
}
return;
}
const changed =
before.exists !== after.exists || before.canonical !== after.canonical;
if (!changed) {
this.lastSnapshot = after;
return;
}
const event: LspConfigChangeEvent = {
path: this.configPath,
changeType:
!before.exists && after.exists
? 'created'
: before.exists && !after.exists
? 'deleted'
: 'modified',
};
debugLogger.info(`LSP config changed: ${event.changeType} ${event.path}`);
const notified = await this.notifyListener(event);
if (notified) {
this.lastSnapshot = after;
}
}
/**
* Reads `.lsp.json` as a single operation. ENOENT is treated as deletion so a
* file removed during a filesystem race still reconciles servers to empty.
*/
private readSnapshot(): LspConfigSnapshot {
let raw: string;
try {
raw = fs.readFileSync(this.configPath, 'utf-8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { exists: false };
}
debugLogger.warn('Failed to read .lsp.json:', error);
return {
exists: true,
error:
'Failed to read .lsp.json; existing LSP runtime state is unchanged.',
};
}
try {
const parsed = JSON.parse(raw) as unknown;
return { exists: true, canonical: canonicalize(parsed) };
} catch (error) {
debugLogger.warn('Failed to parse .lsp.json:', error);
return {
exists: true,
error:
'Invalid JSON in .lsp.json; existing LSP runtime state is unchanged.',
};
}
}
/**
* Runs the listener with timeout isolation so a hung reload cannot stall CLI.
*
* Returns whether the listener completed successfully; callers use this to
* decide whether the semantic snapshot can advance or should be retried.
*/
private async notifyListener(event: LspConfigChangeEvent): Promise<boolean> {
if (!this.listener) return true;
const TIMEOUT_MS = LspConfigWatcher.LISTENER_TIMEOUT_MS;
let timerId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timerId = setTimeout(
() =>
reject(
new Error(
`LSP config change listener timeout after ${TIMEOUT_MS}ms`,
),
),
TIMEOUT_MS,
);
if (
typeof timerId === 'object' &&
timerId !== null &&
'unref' in timerId
) {
(timerId as { unref: () => void }).unref();
}
});
const listenerPromise = Promise.resolve().then(() =>
this.listener?.(event),
);
try {
await Promise.race([listenerPromise, timeoutPromise]);
return true;
} catch (error) {
debugLogger.warn('LSP config change listener error:', error);
return false;
} finally {
if (timerId !== undefined) clearTimeout(timerId);
void listenerPromise.catch(() => undefined);
}
}
}
function canonicalize(value: unknown): string {
return JSON.stringify(sortJsonValue(value));
}
function sortJsonValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(sortJsonValue);
}
if (value && typeof value === 'object') {
// Object.create(null) avoids prototype pollution from __proto__ keys
const sorted = Object.create(null) as Record<string, unknown>;
for (const key of Object.keys(value).sort()) {
sorted[key] = sortJsonValue((value as Record<string, unknown>)[key]);
}
return sorted;
}
return value;
}

View file

@ -17,6 +17,7 @@ import { readFileSync } from 'node:fs';
import {
createNonInteractivePromptId,
main,
registerLspHotReload,
setupUnhandledRejectionHandler,
validateDnsResolutionOrder,
} from './gemini.js';
@ -29,6 +30,13 @@ import { ApprovalMode, OutputFormat } from '@qwen-code/qwen-code-core';
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
const mockHandleListExtensions = vi.hoisted(() => vi.fn());
const lspConfigWatcherMock = vi.hoisted(() => ({
instances: [] as Array<{
listener?: (event: unknown) => void | Promise<void>;
startWatching: ReturnType<typeof vi.fn>;
stopWatching: ReturnType<typeof vi.fn>;
}>,
}));
describe('gemini import boundary', () => {
it('does not statically import ACP or noninteractive auth branches', () => {
@ -78,6 +86,8 @@ vi.mock('./config/config.js', () => ({
getSandbox: vi.fn(() => false),
getQuestion: vi.fn(() => ''),
isInteractive: () => false,
isLspEnabled: () => false,
getLspClient: () => undefined,
getWarnings: vi.fn(() => []),
isSafeMode: vi.fn(() => false),
getModelsConfig: vi.fn(() => ({ getCurrentAuthType: () => null })),
@ -161,6 +171,35 @@ vi.mock('./config/settingsWatcher.js', () => ({
},
}));
vi.mock('./config/lsp-config-watcher.js', () => ({
LspConfigWatcher: class {
listener?: (event: unknown) => void | Promise<void>;
startWatching = vi.fn(
(listener: (event: unknown) => void | Promise<void>) => {
this.listener = listener;
},
);
stopWatching = vi.fn();
constructor() {
lspConfigWatcherMock.instances.push(this);
}
},
}));
function withLspDisabledConfig<T extends object>(
config: T,
): T & {
isLspEnabled: () => boolean;
getLspClient: () => undefined;
} {
return {
isLspEnabled: () => false,
getLspClient: () => undefined,
...config,
};
}
describe('gemini.tsx main function', () => {
let originalEnvGeminiSandbox: string | undefined;
let originalEnvSandbox: string | undefined;
@ -169,6 +208,7 @@ describe('gemini.tsx main function', () => {
[];
beforeEach(() => {
lspConfigWatcherMock.instances.length = 0;
// Store and clear sandbox-related env variables to ensure a consistent test environment
originalEnvGeminiSandbox = process.env['QWEN_SANDBOX'];
originalEnvSandbox = process.env['SANDBOX'];
@ -417,6 +457,187 @@ describe('gemini.tsx main function', () => {
);
});
describe('registerLspHotReload', () => {
it('does not register a watcher when LSP is disabled', () => {
const registerCleanup = vi.fn();
registerLspHotReload(
withLspDisabledConfig({
getProjectRoot: () => '/workspace',
}) as unknown as Config,
registerCleanup,
);
expect(lspConfigWatcherMock.instances).toHaveLength(0);
expect(registerCleanup).not.toHaveBeenCalled();
});
it('does not register a watcher when the client cannot reinitialize', () => {
const registerCleanup = vi.fn();
registerLspHotReload(
{
isLspEnabled: () => true,
getLspClient: () => ({}),
getProjectRoot: () => '/workspace',
} as unknown as Config,
registerCleanup,
);
expect(lspConfigWatcherMock.instances).toHaveLength(0);
expect(registerCleanup).not.toHaveBeenCalled();
});
it('emits an LSP status update after successful reload', async () => {
const registerCleanup = vi.fn();
const reinitializeLsp = vi.fn(async () => ({
reconcile: {
added: ['clangd'],
removed: [],
restarted: [],
unchanged: [],
failed: [],
},
skipped: [],
}));
registerLspHotReload(
{
isLspEnabled: () => true,
getLspClient: () => ({ reinitialize: vi.fn() }),
getProjectRoot: () => '/workspace',
reinitializeLsp,
} as unknown as Config,
registerCleanup,
);
await lspConfigWatcherMock.instances[0]?.listener?.({
path: '/workspace/.lsp.json',
changeType: 'modified',
});
expect(reinitializeLsp).toHaveBeenCalledOnce();
expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.LspStatusChanged);
});
it('emits an LSP status update when reload is skipped by the config', async () => {
const reinitializeLsp = vi.fn(async () => undefined);
registerLspHotReload(
{
isLspEnabled: () => true,
getLspClient: () => ({ reinitialize: vi.fn() }),
getProjectRoot: () => '/workspace',
reinitializeLsp,
} as unknown as Config,
vi.fn(),
);
await lspConfigWatcherMock.instances[0]?.listener?.({
path: '/workspace/.lsp.json',
changeType: 'modified',
});
expect(reinitializeLsp).toHaveBeenCalledOnce();
expect(appEvents.emit).not.toHaveBeenCalledWith(
AppEvent.LogError,
expect.any(String),
);
expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.LspStatusChanged);
});
it('emits a user-visible error and rejects when reload fails', async () => {
const reinitializeLsp = vi.fn(async () => {
throw new Error('invalid lsp json');
});
registerLspHotReload(
{
isLspEnabled: () => true,
getLspClient: () => ({ reinitialize: vi.fn() }),
getProjectRoot: () => '/workspace',
reinitializeLsp,
} as unknown as Config,
vi.fn(),
);
await expect(
lspConfigWatcherMock.instances[0]?.listener?.({
path: '/workspace/.lsp.json',
changeType: 'modified',
}),
).rejects.toThrow('invalid lsp json');
expect(appEvents.emit).toHaveBeenCalledWith(
AppEvent.LogError,
'Failed to reload LSP server settings: invalid lsp json. Some LSP servers may have been partially updated. Run with --debug for details.',
);
});
it('emits a user-visible error and rejects when reload has failed servers', async () => {
const reinitializeLsp = vi.fn(async () => ({
reconcile: {
added: [],
removed: [],
restarted: [],
unchanged: [],
failed: ['clangd'],
},
skipped: [],
}));
registerLspHotReload(
{
isLspEnabled: () => true,
getLspClient: () => ({ reinitialize: vi.fn() }),
getProjectRoot: () => '/workspace',
reinitializeLsp,
} as unknown as Config,
vi.fn(),
);
await expect(
lspConfigWatcherMock.instances[0]?.listener?.({
path: '/workspace/.lsp.json',
changeType: 'modified',
}),
).rejects.toThrow('LSP reload partially completed');
expect(appEvents.emit).toHaveBeenCalledWith(
AppEvent.LogError,
'LSP reload partially completed: changed=<none>, failed=clangd. Run with --debug for details.',
);
expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.LspStatusChanged);
});
it('surfaces invalid config without reinitializing LSP', async () => {
const reinitializeLsp = vi.fn();
registerLspHotReload(
{
isLspEnabled: () => true,
getLspClient: () => ({ reinitialize: vi.fn() }),
getProjectRoot: () => '/workspace',
reinitializeLsp,
} as unknown as Config,
vi.fn(),
);
await lspConfigWatcherMock.instances[0]?.listener?.({
path: '/workspace/.lsp.json',
changeType: 'invalid',
error:
'Invalid JSON in .lsp.json; existing LSP runtime state is unchanged.',
});
expect(reinitializeLsp).not.toHaveBeenCalled();
expect(appEvents.emit).toHaveBeenCalledWith(
AppEvent.LogError,
'Invalid JSON in .lsp.json; existing LSP runtime state is unchanged.',
);
});
});
it('writes non-interactive warnings discovered during config initialization', async () => {
const originalNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH'];
const originalIsTTY = Object.getOwnPropertyDescriptor(

View file

@ -6,6 +6,7 @@
import {
AuthType,
type Config,
InputFormat,
isDebugLoggingDegraded,
isBareMode,
@ -40,6 +41,7 @@ import {
} from './config/settings.js';
import { SettingsWatcher } from './config/settingsWatcher.js';
import { registerMcpHotReload } from './config/hot-reload.js';
import { LspConfigWatcher } from './config/lsp-config-watcher.js';
import { initializeI18n, resolveLanguageSetting } from './i18n/index.js';
import {
setupStartupWorktree,
@ -611,6 +613,8 @@ export async function main() {
registerCleanup(disposeMcpHotReload);
}
registerLspHotReload(config, registerCleanup);
// Phase D-1: persist the WorktreeSession sidecar so Phase C's restore
// machinery on a subsequent `--resume` picks the worktree back up, and
// capture any override of a previously-resumed session's worktree so
@ -1049,3 +1053,107 @@ export async function main() {
export function createNonInteractivePromptId(sessionId: string): string {
return `${sessionId}########0`;
}
/**
* Watches `.lsp.json` for changes and reconciles running LSP servers
* (add / remove / restart) without requiring a session restart.
*
* Silently no-ops when LSP is disabled or the active client does not
* support runtime reinitialization.
*
* Emits {@link AppEvent.LspStatusChanged} after every successful reload
* so the UI can reflect the new server state.
*/
export function registerLspHotReload(
config: Config,
registerCleanup: (fn: () => void | Promise<void>) => void,
): void {
if (
config.isLspEnabled?.() !== true ||
!config.getLspClient?.()?.reinitialize
) {
return;
}
const lspConfigWatcher = new LspConfigWatcher(config.getProjectRoot());
debugLogger.info(
`Registering LSP config hot reload watcher for ${config.getProjectRoot()}`,
);
lspConfigWatcher.startWatching(async (event) => {
if (event.changeType === 'invalid') {
debugLogger.warn(`Invalid LSP config file ${event.path}: ${event.error}`);
appEvents.emit(AppEvent.LogError, event.error);
return;
}
debugLogger.info(
`Reloading LSP server settings: changeType=${event.changeType}, path=${event.path}`,
);
let errorReported = false;
try {
const result = await config.reinitializeLsp();
if (result) {
const failedServers = getRuntimeReloadFailedNames(result.reconcile);
debugLogger.info(
`Reloaded LSP server settings: added=${formatRuntimeReloadNames(
result.reconcile.added,
)}, removed=${formatRuntimeReloadNames(
result.reconcile.removed,
)}, restarted=${formatRuntimeReloadNames(
result.reconcile.restarted,
)}, unchanged=${formatRuntimeReloadNames(
result.reconcile.unchanged,
)}, failed=${formatRuntimeReloadNames(
failedServers,
)}, skipped=${formatRuntimeReloadNames(
result.skipped.map((server) => server.name),
)}`,
);
if (failedServers.length > 0) {
appEvents.emit(AppEvent.LspStatusChanged);
const changedServers = [
...result.reconcile.added,
...result.reconcile.removed,
...result.reconcile.restarted,
];
const message = `LSP reload partially completed: changed=${formatRuntimeReloadNames(
changedServers,
)}, failed=${formatRuntimeReloadNames(
failedServers,
)}. Run with --debug for details.`;
appEvents.emit(AppEvent.LogError, message);
errorReported = true;
throw new Error(message);
}
} else {
debugLogger.info(
'Skipped LSP server settings reload because LSP is disabled or no client is available',
);
}
appEvents.emit(AppEvent.LspStatusChanged);
} catch (error) {
debugLogger.warn('Failed to reload LSP server settings:', error);
if (!errorReported) {
const message =
error instanceof Error
? `Failed to reload LSP server settings: ${error.message}. Some LSP servers may have been partially updated. Run with --debug for details.`
: 'Failed to reload LSP server settings; some LSP servers may have been partially updated. Run with --debug for details.';
appEvents.emit(AppEvent.LogError, message);
}
throw error;
}
});
registerCleanup(() => lspConfigWatcher.stopWatching());
}
function formatRuntimeReloadNames(names: readonly string[]): string {
return names.length === 0 ? '<none>' : names.join(',');
}
/**
* Reads the optional failed bucket defensively because the CLI may typecheck
* against stale core dist declarations during local development.
*/
function getRuntimeReloadFailedNames(reconcile: {
failed?: readonly string[];
}): readonly string[] {
return reconcile.failed ?? [];
}

View file

@ -18,6 +18,7 @@ export enum AppEvent {
* re-evaluate mid-session instead of only at startup. See issue #4615.
*/
McpPendingApprovalChanged = 'mcp-pending-approval-changed',
LspStatusChanged = 'lsp-status-changed',
}
export const appEvents = new EventEmitter();

View file

@ -1965,6 +1965,158 @@ describe('Server Config (config.ts)', () => {
});
});
it('should no-op LSP reinitialize when disabled or unavailable', async () => {
const disabledConfig = new Config({
...baseParams,
lsp: { enabled: false },
});
await expect(disabledConfig.reinitializeLsp()).resolves.toBeUndefined();
const noClientConfig = new Config({
...baseParams,
lsp: { enabled: true },
});
await expect(noClientConfig.reinitializeLsp()).resolves.toBeUndefined();
});
it('should delegate LSP reinitialize to the configured client', async () => {
const result = {
reconcile: {
added: ['tsserver'],
removed: [],
restarted: [],
unchanged: [],
failed: [],
},
skipped: [],
};
const reinitialize = vi.fn().mockResolvedValue(result);
const config = new Config({
...baseParams,
lsp: { enabled: true },
lspClient: {
reinitialize,
} as unknown as ConfigParameters['lspClient'],
});
await expect(config.reinitializeLsp()).resolves.toBe(result);
expect(reinitialize).toHaveBeenCalledOnce();
});
it('should surface partial LSP reinitialize failures in status snapshot', async () => {
const result = {
reconcile: {
added: ['tsserver'],
removed: [],
restarted: [],
unchanged: [],
failed: ['clangd'],
},
skipped: [],
};
const config = new Config({
...baseParams,
lsp: { enabled: true },
lspClient: {
reinitialize: vi.fn().mockResolvedValue(result),
} as unknown as ConfigParameters['lspClient'],
});
await expect(config.reinitializeLsp()).resolves.toBe(result);
expect(config.getLspStatusSnapshot()).toMatchObject({
initializationError: 'LSP reload partially failed: clangd',
});
});
it('should surface LSP reinitialize failures in status snapshot', async () => {
const config = new Config({
...baseParams,
lsp: { enabled: true },
lspClient: {
reinitialize: vi.fn().mockRejectedValue(new Error('invalid lsp json')),
} as unknown as ConfigParameters['lspClient'],
});
await expect(config.reinitializeLsp()).rejects.toThrow('invalid lsp json');
expect(config.getLspStatusSnapshot()).toMatchObject({
initializationError: 'invalid lsp json',
});
});
it('should clear previous LSP reinitialize failures after recovery', async () => {
const result = {
reconcile: {
added: [],
removed: [],
restarted: [],
unchanged: ['tsserver'],
failed: [],
},
skipped: [],
};
const reinitialize = vi
.fn()
.mockRejectedValueOnce(new Error('invalid lsp json'))
.mockResolvedValueOnce(result);
const config = new Config({
...baseParams,
lsp: { enabled: true },
lspClient: {
reinitialize,
} as unknown as ConfigParameters['lspClient'],
});
await expect(config.reinitializeLsp()).rejects.toThrow('invalid lsp json');
expect(config.getLspStatusSnapshot()).toMatchObject({
initializationError: 'invalid lsp json',
});
await expect(config.reinitializeLsp()).resolves.toBe(result);
expect(config.getLspStatusSnapshot().initializationError).toBeUndefined();
});
it('should clear partial LSP reinitialize failures after full recovery', async () => {
const partialFailure = {
reconcile: {
added: [],
removed: [],
restarted: [],
unchanged: [],
failed: ['clangd'],
},
skipped: [],
};
const success = {
reconcile: {
added: [],
removed: [],
restarted: [],
unchanged: ['clangd'],
failed: [],
},
skipped: [],
};
const reinitialize = vi
.fn()
.mockResolvedValueOnce(partialFailure)
.mockResolvedValueOnce(success);
const config = new Config({
...baseParams,
lsp: { enabled: true },
lspClient: {
reinitialize,
} as unknown as ConfigParameters['lspClient'],
});
await expect(config.reinitializeLsp()).resolves.toBe(partialFailure);
expect(config.getLspStatusSnapshot()).toMatchObject({
initializationError: 'LSP reload partially failed: clangd',
});
await expect(config.reinitializeLsp()).resolves.toBe(success);
expect(config.getLspStatusSnapshot().initializationError).toBeUndefined();
});
describe('initialize', () => {
it('should throw an error if initialized more than once', async () => {
const config = new Config({

View file

@ -81,7 +81,11 @@ import type {
ArtifactHostConfig,
ArtifactOssConfig,
} from '../tools/artifact/publisher.js';
import type { LspClient, LspStatusSnapshot } from '../lsp/types.js';
import type {
LspClient,
LspServiceReinitializeResult,
LspStatusSnapshot,
} from '../lsp/types.js';
import type { InstructionLoadReason } from '../hooks/types.js';
import { ApprovalMode } from './approval-mode.js';
@ -4455,7 +4459,7 @@ export class Config {
if (this.lspClient) {
return {
...this.createLspStatusSnapshot(true),
...this.createLspStatusSnapshot(true, this.lspInitializationError),
statusUnavailable: true,
};
}
@ -4496,10 +4500,38 @@ export class Config {
if (this.initialized) {
throw new Error('Cannot set LSP status after initialization');
}
this.setRuntimeLspInitializationError(error);
}
private setRuntimeLspInitializationError(
error: Error | string | undefined,
): void {
this.lspInitializationError =
error instanceof Error ? error.message : error;
}
async reinitializeLsp(): Promise<LspServiceReinitializeResult | undefined> {
if (!this.isLspEnabled() || !this.lspClient?.reinitialize) {
return undefined;
}
try {
const result = await this.lspClient.reinitialize();
if (result.reconcile.failed.length > 0) {
this.setRuntimeLspInitializationError(
`LSP reload partially failed: ${result.reconcile.failed.join(', ')}`,
);
} else {
this.setRuntimeLspInitializationError(undefined);
}
return result;
} catch (error) {
this.setRuntimeLspInitializationError(
error instanceof Error ? error : String(error),
);
throw error;
}
}
getSessionSubagents(): SubagentConfig[] {
return this.sessionSubagents;
}

View file

@ -316,6 +316,7 @@ export * from './ide/types.js';
// ============================================================================
export * from './lsp/constants.js';
export * from './lsp/configHash.js';
export * from './lsp/LspConfigLoader.js';
export * from './lsp/LspConnectionFactory.js';
export * from './lsp/LspResponseNormalizer.js';

View file

@ -154,6 +154,109 @@ describe('LspConfigLoader config-driven behavior', () => {
mock.restore();
}
});
it('strict user config loading rejects invalid server entries', async () => {
mock({
[workspaceRoot]: {
'.lsp.json': JSON.stringify({
typescript: {
transport: 'stdio',
},
}),
},
});
const loader = new LspConfigLoader(workspaceRoot);
const result = await loader.loadUserConfigsStrict();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain(
'Invalid LSP server config in /workspace/.lsp.json: typescript',
);
}
});
it('strict user config loading accepts empty object as explicit empty config', async () => {
mock({
[workspaceRoot]: {
'.lsp.json': JSON.stringify({}),
},
});
const loader = new LspConfigLoader(workspaceRoot);
const result = await loader.loadUserConfigsStrict();
expect(result).toEqual({ ok: true, configs: [] });
});
it('strict user config loading treats deleted config as empty', async () => {
mock({
[workspaceRoot]: {},
});
const loader = new LspConfigLoader(workspaceRoot);
const result = await loader.loadUserConfigsStrict();
expect(result).toEqual({ ok: true, configs: [] });
});
it('non-strict user config loading skips invalid entries without rejecting all configs', async () => {
mock({
[workspaceRoot]: {
'.lsp.json': JSON.stringify({
typescript: {
command: 'typescript-language-server',
},
invalid: {
transport: 'stdio',
},
}),
},
});
const loader = new LspConfigLoader(workspaceRoot);
const configs = await loader.loadUserConfigs();
expect(configs).toHaveLength(1);
expect(configs[0]?.name).toBe('typescript-language-server');
});
it('non-strict user config loading returns empty configs for malformed JSON', async () => {
mock({
[workspaceRoot]: {
'.lsp.json': '{',
},
});
const loader = new LspConfigLoader(workspaceRoot);
const configs = await loader.loadUserConfigs();
expect(configs).toEqual([]);
});
it('forces user configs to require trusted workspaces', async () => {
mock({
[workspaceRoot]: {
'.lsp.json': JSON.stringify({
typescript: {
command: 'typescript-language-server',
trustRequired: false,
},
}),
},
});
const loader = new LspConfigLoader(workspaceRoot);
await expect(loader.loadUserConfigs()).resolves.toEqual([
expect.objectContaining({ trustRequired: true }),
]);
await expect(loader.loadUserConfigsStrict()).resolves.toEqual({
ok: true,
configs: [expect.objectContaining({ trustRequired: true })],
});
});
});
describe('LspConfigLoader extension configs', () => {

View file

@ -22,6 +22,10 @@ import { createDebugLogger } from '../utils/debugLogger.js';
const debugLogger = createDebugLogger('LSP');
const MAX_TCP_PORT = 65_535;
export type LspUserConfigLoadResult =
| { ok: true; configs: LspServerConfig[] }
| { ok: false; error: Error };
export class LspConfigLoader {
constructor(private readonly workspaceRoot: string) {}
@ -38,13 +42,32 @@ export class LspConfigLoader {
try {
const configContent = fs.readFileSync(lspConfigPath, 'utf-8');
const data = JSON.parse(configContent);
return this.parseConfigSource(data, lspConfigPath);
return this.parseConfigSource(data, lspConfigPath, {
forceTrustRequired: true,
});
} catch (error) {
debugLogger.warn('Failed to load user .lsp.json config:', error);
return [];
}
}
async loadUserConfigsStrict(): Promise<LspUserConfigLoadResult> {
const lspConfigPath = path.join(this.workspaceRoot, '.lsp.json');
try {
const configContent = fs.readFileSync(lspConfigPath, 'utf-8');
const data = JSON.parse(configContent);
return this.parseUserConfigSourceStrict(data, lspConfigPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { ok: true, configs: [] };
}
return {
ok: false,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
/**
* Load LSP configurations declared by extensions (Claude plugins).
*/
@ -168,6 +191,7 @@ export class LspConfigLoader {
private parseConfigSource(
source: unknown,
origin: string,
options: { forceTrustRequired?: boolean } = {},
): LspServerConfig[] {
if (!this.isRecord(source)) {
return [];
@ -185,7 +209,9 @@ export class LspConfigLoader {
const name =
typeof spec['command'] === 'string' ? (spec['command'] as string) : key;
const config = this.buildServerConfig(name, languages, spec, origin);
const config = this.buildServerConfig(name, languages, spec, origin, {
forceTrustRequired: options.forceTrustRequired,
});
if (config) {
configs.push(config);
}
@ -194,6 +220,45 @@ export class LspConfigLoader {
return configs;
}
private parseUserConfigSourceStrict(
source: unknown,
origin: string,
): LspUserConfigLoadResult {
if (!this.isRecord(source)) {
return {
ok: false,
error: new Error(`LSP config in ${origin} must be an object`),
};
}
const configs: LspServerConfig[] = [];
for (const [key, spec] of Object.entries(source)) {
if (!this.isRecord(spec)) {
return {
ok: false,
error: new Error(
`LSP config error in ${origin}: ${key} must be an object`,
),
};
}
const languages = [key];
const name =
typeof spec['command'] === 'string' ? (spec['command'] as string) : key;
const config = this.buildServerConfig(name, languages, spec, origin, {
forceTrustRequired: true,
});
if (!config) {
return {
ok: false,
error: new Error(`Invalid LSP server config in ${origin}: ${key}`),
};
}
configs.push(config);
}
return { ok: true, configs };
}
private resolveExtensionConfigPath(
extensionPath: string,
configPath: string,
@ -221,6 +286,7 @@ export class LspConfigLoader {
languages: string[],
spec: Record<string, unknown>,
origin: string,
options: { forceTrustRequired?: boolean } = {},
): LspServerConfig | null {
const transport = this.normalizeTransport(spec['transport']);
const command =
@ -249,8 +315,9 @@ export class LspConfigLoader {
? (spec['restartOnCrash'] as boolean)
: undefined;
const maxRestarts = this.normalizeMaxRestarts(spec['maxRestarts']);
const trustRequired =
typeof spec['trustRequired'] === 'boolean'
const trustRequired = options.forceTrustRequired
? true
: typeof spec['trustRequired'] === 'boolean'
? (spec['trustRequired'] as boolean)
: true;
const socket = this.normalizeSocketOptions(spec);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -30,6 +30,7 @@ import type {
LspSymbolInformation,
LspWorkspaceEdit,
LspServerStatusInfo,
LspServiceReinitializeResult,
} from './types.js';
import type { NativeLspService } from './NativeLspService.js';
@ -81,6 +82,10 @@ export class NativeLspClient implements LspClient {
return result;
}
reinitialize(): Promise<LspServiceReinitializeResult> {
return this.service.reinitialize();
}
/**
* Search for symbols across the workspace.
*

View file

@ -144,6 +144,963 @@ describe('NativeLspService', () => {
expect(status).toBeDefined();
});
test('reinitialize reconciles valid .lsp.json configs', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-reinit-'));
try {
fs.writeFileSync(
path.join(tempDir, '.lsp.json'),
JSON.stringify({
typescript: {
command: 'typescript-language-server',
args: ['--stdio'],
},
}),
);
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ workspaceRoot: tempDir },
);
const reconcileServerConfigs = vi.fn(async () => ({
added: ['typescript-language-server'],
removed: [],
restarted: [],
unchanged: [],
failed: [],
}));
(service as unknown as { serverManager: unknown }).serverManager = {
reconcileServerConfigs,
};
const result = await service.reinitialize();
expect(reconcileServerConfigs).toHaveBeenCalledWith([
expect.objectContaining({
name: 'typescript-language-server',
languages: ['typescript'],
}),
]);
expect(result.reconcile.added).toEqual(['typescript-language-server']);
expect(result.skipped).toEqual([]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('reinitialize preserves runtime state on invalid .lsp.json', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-invalid-'));
try {
fs.writeFileSync(path.join(tempDir, '.lsp.json'), '{');
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ workspaceRoot: tempDir },
);
const reconcileServerConfigs = vi.fn();
(service as unknown as { serverManager: unknown }).serverManager = {
reconcileServerConfigs,
};
await expect(service.reinitialize()).rejects.toThrow();
expect(reconcileServerConfigs).not.toHaveBeenCalled();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('reinitialize preserves runtime state on invalid server entries', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-invalid-'));
try {
fs.writeFileSync(
path.join(tempDir, '.lsp.json'),
JSON.stringify({
typescript: {
transport: 'stdio',
},
}),
);
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ workspaceRoot: tempDir },
);
const reconcileServerConfigs = vi.fn();
(service as unknown as { serverManager: unknown }).serverManager = {
reconcileServerConfigs,
};
await expect(service.reinitialize()).rejects.toThrow(
'Invalid LSP server config',
);
expect(reconcileServerConfigs).not.toHaveBeenCalled();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('reinitialize queue continues after a failed reload', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-queue-error-'));
try {
fs.writeFileSync(path.join(tempDir, '.lsp.json'), '{');
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ workspaceRoot: tempDir },
);
const reconcileServerConfigs = vi.fn(async () => ({
added: ['typescript-language-server'],
removed: [],
restarted: [],
unchanged: [],
failed: [],
}));
(service as unknown as { serverManager: unknown }).serverManager = {
reconcileServerConfigs,
};
await expect(service.reinitialize()).rejects.toThrow();
expect(reconcileServerConfigs).not.toHaveBeenCalled();
fs.writeFileSync(
path.join(tempDir, '.lsp.json'),
JSON.stringify({
typescript: {
command: 'typescript-language-server',
},
}),
);
const result = await service.reinitialize();
expect(reconcileServerConfigs).toHaveBeenCalledOnce();
expect(result.reconcile.added).toEqual(['typescript-language-server']);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('reinitialize replays open documents after restarting servers', async () => {
vi.useFakeTimers();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-replay-'));
try {
const filePath = path.join(tempDir, 'main.ts');
const uri = pathToFileURL(filePath).toString();
fs.writeFileSync(filePath, 'const value = 1;\n', 'utf-8');
fs.writeFileSync(
path.join(tempDir, '.lsp.json'),
JSON.stringify({
typescript: {
command: 'typescript-language-server',
},
}),
);
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ workspaceRoot: tempDir },
);
const connection = {
listen: vi.fn(),
send: vi.fn(),
onNotification: vi.fn(),
onRequest: vi.fn(),
request: vi.fn(),
initialize: vi.fn(),
shutdown: vi.fn(),
end: vi.fn(),
};
const handle = {
config: {
name: 'typescript-language-server',
languages: ['typescript'],
command: 'typescript-language-server',
args: [],
transport: 'stdio',
},
status: 'READY',
connection,
};
const reconcileServerConfigs = vi.fn(async () => ({
added: [],
removed: [],
restarted: ['typescript-language-server'],
unchanged: [],
failed: [],
}));
(service as unknown as { serverManager: unknown }).serverManager = {
reconcileServerConfigs,
getHandles: () => new Map([['typescript-language-server', handle]]),
};
const internals = service as unknown as {
openedDocuments: Map<string, Set<string>>;
};
internals.openedDocuments.set(
'typescript-language-server',
new Set([uri]),
);
const reinitialize = service.reinitialize();
await vi.runAllTimersAsync();
await reinitialize;
expect(connection.send).toHaveBeenCalledWith(
expect.objectContaining({
method: 'textDocument/didOpen',
params: {
textDocument: expect.objectContaining({
uri,
languageId: 'typescript',
text: 'const value = 1;\n',
}),
},
}),
);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('reinitialize continues replaying documents after one server send fails', async () => {
vi.useFakeTimers();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-replay-'));
try {
const firstPath = path.join(tempDir, 'first.ts');
const secondPath = path.join(tempDir, 'second.py');
const firstUri = pathToFileURL(firstPath).toString();
const secondUri = pathToFileURL(secondPath).toString();
fs.writeFileSync(firstPath, 'const value = 1;\n', 'utf-8');
fs.writeFileSync(secondPath, 'value = 1\n', 'utf-8');
fs.writeFileSync(
path.join(tempDir, '.lsp.json'),
JSON.stringify({
typescript: {
command: 'typescript-language-server',
},
python: {
command: 'pyright-langserver',
},
}),
);
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ workspaceRoot: tempDir },
);
const firstConnection = {
listen: vi.fn(),
send: vi.fn(() => {
throw new Error('broken pipe');
}),
onNotification: vi.fn(),
onRequest: vi.fn(),
request: vi.fn(),
initialize: vi.fn(),
shutdown: vi.fn(),
end: vi.fn(),
};
const secondConnection = {
listen: vi.fn(),
send: vi.fn(),
onNotification: vi.fn(),
onRequest: vi.fn(),
request: vi.fn(),
initialize: vi.fn(),
shutdown: vi.fn(),
end: vi.fn(),
};
const handles = new Map([
[
'typescript-language-server',
{
config: {
name: 'typescript-language-server',
languages: ['typescript'],
command: 'typescript-language-server',
args: [],
transport: 'stdio',
},
status: 'READY',
connection: firstConnection,
},
],
[
'pyright-langserver',
{
config: {
name: 'pyright-langserver',
languages: ['python'],
command: 'pyright-langserver',
args: [],
transport: 'stdio',
},
status: 'READY',
connection: secondConnection,
},
],
]);
const reconcileServerConfigs = vi.fn(async () => ({
added: [],
removed: [],
restarted: ['typescript-language-server', 'pyright-langserver'],
unchanged: [],
failed: [],
}));
(service as unknown as { serverManager: unknown }).serverManager = {
reconcileServerConfigs,
getHandles: () => handles,
};
const internals = service as unknown as {
openedDocuments: Map<string, Set<string>>;
lastConnections: Map<string, unknown>;
};
internals.openedDocuments.set(
'typescript-language-server',
new Set([firstUri]),
);
internals.openedDocuments.set('pyright-langserver', new Set([secondUri]));
const reinitialize = service.reinitialize();
await vi.runAllTimersAsync();
await expect(reinitialize).resolves.toBeDefined();
expect(firstConnection.send).toHaveBeenCalledOnce();
expect(internals.lastConnections.has('typescript-language-server')).toBe(
false,
);
expect(internals.lastConnections.get('pyright-langserver')).toBe(
secondConnection,
);
expect(secondConnection.send).toHaveBeenCalledWith(
expect.objectContaining({
method: 'textDocument/didOpen',
params: {
textDocument: expect.objectContaining({
uri: secondUri,
languageId: 'python',
text: 'value = 1\n',
}),
},
}),
);
} finally {
vi.useRealTimers();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('reinitialize preserves open documents for failed servers until a later restart succeeds', async () => {
vi.useFakeTimers();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-failed-'));
try {
const filePath = path.join(tempDir, 'index.ts');
const uri = pathToFileURL(filePath).toString();
fs.writeFileSync(filePath, 'const value = 1;\n', 'utf-8');
fs.writeFileSync(
path.join(tempDir, '.lsp.json'),
JSON.stringify({
typescript: {
command: 'typescript-language-server',
},
}),
);
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ workspaceRoot: tempDir },
);
const connection = {
listen: vi.fn(),
send: vi.fn(),
onNotification: vi.fn(),
onRequest: vi.fn(),
request: vi.fn(),
initialize: vi.fn(),
shutdown: vi.fn(),
end: vi.fn(),
};
const handle = {
config: {
name: 'typescript-language-server',
languages: ['typescript'],
command: 'typescript-language-server',
args: [],
transport: 'stdio',
},
status: 'READY',
connection,
};
const reconcileServerConfigs = vi
.fn()
.mockResolvedValueOnce({
added: [],
removed: [],
restarted: [],
unchanged: [],
failed: ['typescript-language-server'],
})
.mockResolvedValueOnce({
added: [],
removed: [],
restarted: ['typescript-language-server'],
unchanged: [],
failed: [],
});
(service as unknown as { serverManager: unknown }).serverManager = {
reconcileServerConfigs,
getHandles: () => new Map([['typescript-language-server', handle]]),
};
const internals = service as unknown as {
openedDocuments: Map<string, Set<string>>;
};
internals.openedDocuments.set(
'typescript-language-server',
new Set([uri]),
);
await service.reinitialize();
expect(
internals.openedDocuments.get('typescript-language-server'),
).toEqual(new Set([uri]));
const reinitialize = service.reinitialize();
await vi.runAllTimersAsync();
await reinitialize;
expect(connection.send).toHaveBeenCalledWith(
expect.objectContaining({
method: 'textDocument/didOpen',
params: {
textDocument: expect.objectContaining({
uri,
languageId: 'typescript',
text: 'const value = 1;\n',
}),
},
}),
);
} finally {
vi.useRealTimers();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('reinitialize serializes the full snapshot reconcile and replay flow', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-queue-'));
try {
fs.writeFileSync(
path.join(tempDir, '.lsp.json'),
JSON.stringify({
typescript: {
command: 'typescript-language-server',
},
}),
);
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ workspaceRoot: tempDir },
);
let resolveFirstReconcile: (
value: Awaited<ReturnType<typeof reconcileServerConfigs>>,
) => void;
const firstReconcile = new Promise<{
added: string[];
removed: string[];
restarted: string[];
unchanged: string[];
failed: string[];
}>((resolve) => {
resolveFirstReconcile = resolve;
});
const reconcileServerConfigs = vi
.fn()
.mockReturnValueOnce(firstReconcile)
.mockResolvedValueOnce({
added: [],
removed: [],
restarted: [],
unchanged: ['typescript-language-server'],
failed: [],
});
(service as unknown as { serverManager: unknown }).serverManager = {
reconcileServerConfigs,
};
const first = service.reinitialize();
await vi.waitFor(() => {
expect(reconcileServerConfigs).toHaveBeenCalledOnce();
});
const second = service.reinitialize();
await Promise.resolve();
expect(reconcileServerConfigs).toHaveBeenCalledOnce();
resolveFirstReconcile!({
added: ['typescript-language-server'],
removed: [],
restarted: [],
unchanged: [],
failed: [],
});
await first;
await vi.waitFor(() => {
expect(reconcileServerConfigs).toHaveBeenCalledTimes(2);
});
await second;
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('reinitialize snapshots documents opened while reconcile is pending', async () => {
vi.useFakeTimers();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-snapshot-'));
try {
const filePath = path.join(tempDir, 'index.ts');
const uri = pathToFileURL(filePath).toString();
fs.writeFileSync(filePath, 'const value = 1;\n', 'utf-8');
fs.writeFileSync(
path.join(tempDir, '.lsp.json'),
JSON.stringify({
typescript: {
command: 'typescript-language-server',
},
}),
);
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ workspaceRoot: tempDir },
);
const connection = {
listen: vi.fn(),
send: vi.fn(),
onNotification: vi.fn(),
onRequest: vi.fn(),
request: vi.fn(),
initialize: vi.fn(),
shutdown: vi.fn(),
end: vi.fn(),
};
const handle = {
config: {
name: 'typescript-language-server',
languages: ['typescript'],
command: 'typescript-language-server',
args: [],
transport: 'stdio',
},
status: 'READY',
connection,
};
let resolveReconcile: (
value: Awaited<ReturnType<typeof reconcileServerConfigs>>,
) => void;
const reconcilePromise = new Promise<{
added: string[];
removed: string[];
restarted: string[];
unchanged: string[];
failed: string[];
}>((resolve) => {
resolveReconcile = resolve;
});
const reconcileServerConfigs = vi.fn(() => reconcilePromise);
(service as unknown as { serverManager: unknown }).serverManager = {
reconcileServerConfigs,
getHandles: () => new Map([['typescript-language-server', handle]]),
};
const internals = service as unknown as {
openedDocuments: Map<string, Set<string>>;
};
const reinitialize = service.reinitialize();
await vi.waitFor(() => {
expect(reconcileServerConfigs).toHaveBeenCalledOnce();
});
internals.openedDocuments.set(
'typescript-language-server',
new Set([uri]),
);
resolveReconcile!({
added: [],
removed: [],
restarted: ['typescript-language-server'],
unchanged: [],
failed: [],
});
await vi.runAllTimersAsync();
await reinitialize;
expect(connection.send).toHaveBeenCalledWith(
expect.objectContaining({
method: 'textDocument/didOpen',
params: {
textDocument: expect.objectContaining({
uri,
languageId: 'typescript',
text: 'const value = 1;\n',
}),
},
}),
);
} finally {
vi.useRealTimers();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('stop cancels an in-flight reinitialize replay delay', async () => {
vi.useFakeTimers();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-stop-'));
try {
const filePath = path.join(tempDir, 'index.ts');
const uri = pathToFileURL(filePath).toString();
fs.writeFileSync(filePath, 'const value = 1;\n', 'utf-8');
fs.writeFileSync(
path.join(tempDir, '.lsp.json'),
JSON.stringify({
typescript: {
command: 'typescript-language-server',
},
}),
);
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ workspaceRoot: tempDir },
);
const connection = {
listen: vi.fn(),
send: vi.fn(),
onNotification: vi.fn(),
onRequest: vi.fn(),
request: vi.fn(),
initialize: vi.fn(),
shutdown: vi.fn(),
end: vi.fn(),
};
const handle = {
config: {
name: 'typescript-language-server',
languages: ['typescript'],
command: 'typescript-language-server',
args: [],
transport: 'stdio',
},
status: 'READY',
connection,
};
const stopAll = vi.fn(async () => {});
const reconcileServerConfigs = vi.fn(async () => ({
added: [],
removed: [],
restarted: ['typescript-language-server'],
unchanged: [],
failed: [],
}));
(service as unknown as { serverManager: unknown }).serverManager = {
reconcileServerConfigs,
getHandles: () => new Map([['typescript-language-server', handle]]),
stopAll,
};
const internals = service as unknown as {
openedDocuments: Map<string, Set<string>>;
lastConnections: Map<string, unknown>;
};
internals.openedDocuments.set(
'typescript-language-server',
new Set([uri]),
);
internals.lastConnections.set('typescript-language-server', connection);
const reinitialize = service.reinitialize();
await vi.waitFor(() => {
expect(connection.send).toHaveBeenCalledOnce();
});
await service.stop();
await expect(reinitialize).rejects.toThrow('LSP reinitialize cancelled');
expect(stopAll).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
expect(internals.openedDocuments.size).toBe(0);
expect(internals.lastConnections.size).toBe(0);
} finally {
vi.useRealTimers();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('stop cancels queued reinitialize calls before they start', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-stop-queue-'));
try {
fs.writeFileSync(
path.join(tempDir, '.lsp.json'),
JSON.stringify({
typescript: {
command: 'typescript-language-server',
},
}),
);
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ workspaceRoot: tempDir },
);
let resolveFirstReconcile: (
value: Awaited<ReturnType<typeof reconcileServerConfigs>>,
) => void;
const firstReconcile = new Promise<{
added: string[];
removed: string[];
restarted: string[];
unchanged: string[];
failed: string[];
}>((resolve) => {
resolveFirstReconcile = resolve;
});
const reconcileServerConfigs = vi.fn(() => firstReconcile);
const stopAll = vi.fn(async () => {});
(service as unknown as { serverManager: unknown }).serverManager = {
reconcileServerConfigs,
stopAll,
};
const first = service.reinitialize();
await vi.waitFor(() => {
expect(reconcileServerConfigs).toHaveBeenCalledOnce();
});
const second = service.reinitialize();
await service.stop();
resolveFirstReconcile!({
added: ['typescript-language-server'],
removed: [],
restarted: [],
unchanged: [],
failed: [],
});
await expect(first).rejects.toThrow('LSP reinitialize cancelled');
await expect(second).rejects.toThrow('LSP reinitialize cancelled');
expect(reconcileServerConfigs).toHaveBeenCalledOnce();
expect(stopAll).toHaveBeenCalledOnce();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('reinitialize stops all servers when trusted workspace is required but unavailable', async () => {
const tempConfig = new MockConfig();
vi.spyOn(tempConfig, 'isTrustedFolder').mockReturnValue(false);
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ requireTrustedWorkspace: true },
);
const stopAll = vi.fn(async () => {});
const internals = service as unknown as {
serverManager: unknown;
openedDocuments: Map<string, Set<string>>;
lastConnections: Map<string, unknown>;
};
internals.serverManager = {
getHandles: () => new Map([['tsserver', {}]]),
stopAll,
};
internals.openedDocuments.set('tsserver', new Set(['file:///a.ts']));
internals.lastConnections.set('tsserver', {});
const result = await service.reinitialize();
expect(stopAll).toHaveBeenCalledOnce();
expect(result.reconcile.removed).toEqual(['tsserver']);
expect(internals.openedDocuments.has('tsserver')).toBe(false);
expect(internals.lastConnections.has('tsserver')).toBe(false);
});
test('reinitialize skips all user-configured servers in untrusted workspaces', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-untrusted-'));
try {
fs.writeFileSync(
path.join(tempDir, '.lsp.json'),
JSON.stringify({
trusted: {
command: 'trusted-language-server',
languages: ['typescript'],
trustRequired: true,
},
untrusted: {
command: 'untrusted-language-server',
languages: ['javascript'],
trustRequired: false,
},
}),
);
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
vi.spyOn(tempConfig, 'isTrustedFolder').mockReturnValue(false);
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ requireTrustedWorkspace: false, workspaceRoot: tempDir },
);
const reconcileServerConfigs = vi.fn(async () => ({
added: [],
removed: [],
restarted: [],
unchanged: [],
failed: [],
}));
(service as unknown as { serverManager: unknown }).serverManager = {
reconcileServerConfigs,
};
const result = await service.reinitialize();
expect(reconcileServerConfigs).toHaveBeenCalledWith([]);
expect(result.skipped).toEqual([
{
name: 'trusted-language-server',
reason: 'server_trust_required',
},
{
name: 'untrusted-language-server',
reason: 'server_trust_required',
},
]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('discoverAndPrepare skips trust-required servers in untrusted workspaces', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsp-discover-'));
try {
fs.writeFileSync(
path.join(tempDir, '.lsp.json'),
JSON.stringify({
trusted: {
command: 'trusted-language-server',
languages: ['typescript'],
trustRequired: true,
},
untrusted: {
command: 'untrusted-language-server',
languages: ['javascript'],
trustRequired: false,
},
}),
);
const tempConfig = new MockConfig();
tempConfig.rootPath = tempDir;
vi.spyOn(tempConfig, 'isTrustedFolder').mockReturnValue(false);
const service = new NativeLspService(
tempConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
{ requireTrustedWorkspace: false, workspaceRoot: tempDir },
);
await service.discoverAndPrepare();
const handles = Array.from(
(
service as unknown as {
serverManager: { getHandles: () => Map<string, unknown> };
}
).serverManager
.getHandles()
.keys(),
);
expect(handles).toEqual([]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('stop clears document tracking caches', async () => {
const stopAll = vi.fn(async () => {});
const internals = lspService as unknown as {
serverManager: { stopAll: () => Promise<void> };
openedDocuments: Map<string, Set<string>>;
lastConnections: Map<string, unknown>;
};
internals.serverManager = { stopAll };
internals.openedDocuments.set('tsserver', new Set(['file:///a.ts']));
internals.lastConnections.set('tsserver', {});
await lspService.stop();
expect(stopAll).toHaveBeenCalledOnce();
expect(internals.openedDocuments.size).toBe(0);
expect(internals.lastConnections.size).toBe(0);
});
test('should expose a detailed status snapshot for configured servers', () => {
const serverManager = {
getHandles: () =>

View file

@ -38,6 +38,9 @@ import { LspServerManager } from './LspServerManager.js';
import type {
LspConnectionInterface,
LspServerHandle,
LspServerConfig,
LspServiceReinitializeResult,
LspSkippedServer,
LspServerStatus,
LspStatusSnapshot,
NativeLspServiceOptions,
@ -86,6 +89,9 @@ export class NativeLspService {
private normalizer: LspResponseNormalizer;
private openedDocuments = new Map<string, Set<string>>();
private lastConnections = new Map<string, LspConnectionInterface>();
private reinitializeQueue: Promise<unknown> = Promise.resolve();
private reinitializeAbortController: AbortController | undefined;
private stopping = false;
constructor(
config: CoreConfig,
@ -141,7 +147,204 @@ export class NativeLspService {
extensionConfigs,
userConfigs,
);
this.serverManager.setServerConfigs(serverConfigs);
const { admitted, skipped } = this.filterServerConfigs(
serverConfigs,
workspaceTrusted,
);
debugLogger.info(
`Discovered ${admitted.length} LSP server config(s): ${formatServerNames(
admitted.map((config) => config.name),
)}, skipped=${formatServerNames(skipped.map((server) => server.name))}`,
);
this.serverManager.setServerConfigs(admitted);
}
async reinitialize(): Promise<LspServiceReinitializeResult> {
if (this.stopping) {
throw new Error('LSP reinitialize cancelled');
}
const controller = new AbortController();
const run = async () => {
this.throwIfReinitializeAborted(controller.signal);
this.reinitializeAbortController = controller;
try {
return await this.doReinitialize(controller.signal);
} finally {
if (this.reinitializeAbortController === controller) {
this.reinitializeAbortController = undefined;
}
}
};
const next = this.reinitializeQueue.then(run, run);
this.reinitializeQueue = next.catch(() => undefined);
return next;
}
private throwIfReinitializeAborted(signal: AbortSignal): void {
if (this.stopping || signal.aborted) {
throw new Error('LSP reinitialize cancelled');
}
}
private async doReinitialize(
signal: AbortSignal,
): Promise<LspServiceReinitializeResult> {
this.throwIfReinitializeAborted(signal);
const workspaceTrusted = this.config.isTrustedFolder();
debugLogger.info(
`Reinitializing LSP servers: workspaceRoot=${this.workspaceRoot}, trusted=${workspaceTrusted}`,
);
if (this.requireTrustedWorkspace && !workspaceTrusted) {
this.throwIfReinitializeAborted(signal);
const removed = Array.from(this.serverManager.getHandles().keys());
await this.serverManager.stopAll();
this.throwIfReinitializeAborted(signal);
this.clearDocumentTrackingForServers(removed);
const result = {
reconcile: {
added: [],
removed,
restarted: [],
unchanged: [],
failed: [],
},
skipped: [],
};
debugLogger.info(
`LSP reinitialize result: added=<none>, removed=${formatServerNames(
removed,
)}, restarted=<none>, unchanged=<none>, failed=<none>, skipped=<none>`,
);
return result;
}
this.throwIfReinitializeAborted(signal);
const userConfigs = await this.configLoader.loadUserConfigsStrict();
this.throwIfReinitializeAborted(signal);
if (!userConfigs.ok) {
throw userConfigs.error;
}
const extensionConfigs = await this.configLoader.loadExtensionConfigs(
this.getActiveExtensions(),
);
this.throwIfReinitializeAborted(signal);
const serverConfigs = this.configLoader.mergeConfigs(
[],
extensionConfigs,
userConfigs.configs,
);
const { admitted, skipped } = this.filterServerConfigs(
serverConfigs,
workspaceTrusted,
);
this.throwIfReinitializeAborted(signal);
const reconcile = await this.serverManager.reconcileServerConfigs(admitted);
this.throwIfReinitializeAborted(signal);
const restartedOpenDocuments = this.snapshotOpenDocuments(
reconcile.restarted,
);
this.clearDocumentTrackingForServers([
...reconcile.removed,
...reconcile.restarted,
]);
await this.replayOpenDocuments(
reconcile.restarted,
restartedOpenDocuments,
signal,
);
this.throwIfReinitializeAborted(signal);
debugLogger.info(
`LSP reinitialize result: added=${formatServerNames(
reconcile.added,
)}, removed=${formatServerNames(
reconcile.removed,
)}, restarted=${formatServerNames(
reconcile.restarted,
)}, unchanged=${formatServerNames(
reconcile.unchanged,
)}, failed=${formatServerNames(
reconcile.failed,
)}, skipped=${formatServerNames(skipped.map((server) => server.name))}`,
);
return { reconcile, skipped };
}
private filterServerConfigs(
configs: LspServerConfig[],
workspaceTrusted: boolean,
): { admitted: LspServerConfig[]; skipped: LspSkippedServer[] } {
const admitted: LspServerConfig[] = [];
const skipped: LspSkippedServer[] = [];
for (const config of configs) {
if (!workspaceTrusted && config.trustRequired) {
debugLogger.warn(
`LSP server ${config.name} requires trusted workspace, skipping`,
);
skipped.push({ name: config.name, reason: 'server_trust_required' });
continue;
}
admitted.push(config);
}
return { admitted, skipped };
}
private clearDocumentTrackingForServers(serverNames: string[]): void {
for (const name of serverNames) {
this.openedDocuments.delete(name);
this.lastConnections.delete(name);
}
}
private snapshotOpenDocuments(
serverNames: string[],
): Map<string, Set<string>> {
const snapshots = new Map<string, Set<string>>();
for (const name of serverNames) {
const documents = this.openedDocuments.get(name);
if (documents) {
snapshots.set(name, new Set(documents));
}
}
return snapshots;
}
private async replayOpenDocuments(
serverNames: string[],
snapshots: Map<string, Set<string>>,
signal: AbortSignal,
): Promise<void> {
this.throwIfReinitializeAborted(signal);
if (
serverNames.length === 0 ||
!serverNames.some((name) => snapshots.has(name))
) {
return;
}
const readyHandles = new Map(this.getReadyHandles());
for (const name of serverNames) {
this.throwIfReinitializeAborted(signal);
const handle = readyHandles.get(name);
const documents = snapshots.get(name);
if (!handle || !documents) {
continue;
}
let openedAny = false;
for (const uri of documents) {
this.throwIfReinitializeAborted(signal);
try {
openedAny = this.sendDocumentOpen(name, handle, uri) || openedAny;
} catch (error) {
debugLogger.warn(
`Failed to replay document ${uri} for LSP server ${name}:`,
error,
);
}
}
if (openedAny) {
await this.delay(DEFAULT_LSP_DOCUMENT_OPEN_DELAY_MS, signal);
}
}
}
private getActiveExtensions(): Extension[] {
@ -164,7 +367,11 @@ export class NativeLspService {
* Stop all LSP servers
*/
async stop(): Promise<void> {
this.stopping = true;
this.reinitializeAbortController?.abort();
await this.serverManager.stopAll();
this.openedDocuments.clear();
this.lastConnections.clear();
}
/**
@ -286,11 +493,28 @@ export class NativeLspService {
if (lastConnection && lastConnection !== handle.connection) {
this.openedDocuments.delete(serverName);
}
this.lastConnections.set(serverName, handle.connection);
if (!this.sendDocumentOpen(serverName, handle, uri)) {
return false;
}
// Wait for the LSP server to process the newly opened document.
// Without this delay, requests sent immediately after didOpen may return
// empty results because the server hasn't finished analyzing the file.
await this.delay(DEFAULT_LSP_DOCUMENT_OPEN_DELAY_MS);
return true;
}
private sendDocumentOpen(
serverName: string,
handle: LspServerHandle & { connection: LspConnectionInterface },
uri: string,
): boolean {
if (!uri.startsWith('file://')) {
return false;
}
const openedForServer = this.openedDocuments.get(serverName);
if (openedForServer?.has(uri)) {
return false;
@ -330,15 +554,11 @@ export class NativeLspService {
},
});
this.lastConnections.set(serverName, handle.connection);
const nextOpened = openedForServer ?? new Set<string>();
nextOpened.add(uri);
this.openedDocuments.set(serverName, nextOpened);
// Wait for the LSP server to process the newly opened document.
// Without this delay, requests sent immediately after didOpen may return
// empty results because the server hasn't finished analyzing the file.
await this.delay(DEFAULT_LSP_DOCUMENT_OPEN_DELAY_MS);
return true;
}
@ -520,8 +740,24 @@ export class NativeLspService {
return justOpened && !this.serverManager.isTypescriptServer(handle);
}
private async delay(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
private async delay(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await new Promise((resolve) => setTimeout(resolve, ms));
return;
}
this.throwIfReinitializeAborted(signal);
await new Promise<void>((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
reject(new Error('LSP reinitialize cancelled'));
};
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal.addEventListener('abort', onAbort, { once: true });
});
}
/**
@ -1408,3 +1644,7 @@ export class NativeLspService {
return message.includes('No Project');
}
}
function formatServerNames(names: readonly string[]): string {
return names.length === 0 ? '<none>' : names.join(',');
}

View file

@ -0,0 +1,66 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import type { LspServerConfig } from './types.js';
import { lspServerConfigHash } from './configHash.js';
const baseConfig: LspServerConfig = {
name: 'tsserver',
languages: ['typescript'],
command: 'typescript-language-server',
args: ['--stdio'],
transport: 'stdio',
env: { B: '2', A: '1' },
settings: { beta: true, alpha: { z: 1, a: 2 } },
rootUri: 'file:///workspace',
workspaceFolder: '/workspace',
trustRequired: true,
};
describe('lspServerConfigHash', () => {
it('ignores object key order', () => {
const reordered: LspServerConfig = {
...baseConfig,
env: { A: '1', B: '2' },
settings: { alpha: { a: 2, z: 1 }, beta: true },
};
expect(lspServerConfigHash(reordered)).toBe(
lspServerConfigHash(baseConfig),
);
});
it('treats argument order as semantic', () => {
expect(
lspServerConfigHash({
...baseConfig,
args: ['--stdio', '--log'],
}),
).not.toBe(
lspServerConfigHash({
...baseConfig,
args: ['--log', '--stdio'],
}),
);
});
it('changes when runtime config fields change', () => {
expect(
lspServerConfigHash({
...baseConfig,
command: 'other-server',
}),
).not.toBe(lspServerConfigHash(baseConfig));
expect(
lspServerConfigHash({
...baseConfig,
trustRequired: false,
}),
).not.toBe(lspServerConfigHash(baseConfig));
});
});

View file

@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
import type { LspServerConfig } from './types.js';
function sortJsonValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(sortJsonValue);
}
if (value && typeof value === 'object') {
// Object.create(null) avoids prototype pollution from __proto__ keys
const sorted = Object.create(null) as Record<string, unknown>;
for (const key of Object.keys(value).sort()) {
sorted[key] = sortJsonValue((value as Record<string, unknown>)[key]);
}
return sorted;
}
return value;
}
export function lspServerConfigHash(config: LspServerConfig): string {
const hashInput = {
name: config.name,
languages: config.languages,
transport: config.transport,
command: config.command,
args: config.args,
env: config.env,
initializationOptions: config.initializationOptions,
settings: config.settings,
extensionToLanguage: config.extensionToLanguage,
workspaceFolder: config.workspaceFolder,
rootUri: config.rootUri,
startupTimeout: config.startupTimeout,
shutdownTimeout: config.shutdownTimeout,
restartOnCrash: config.restartOnCrash,
maxRestarts: config.maxRestarts,
trustRequired: config.trustRequired,
socket: config.socket,
} satisfies Record<keyof LspServerConfig, unknown>;
const stableConfig = sortJsonValue(hashInput);
return createHash('sha256')
.update(JSON.stringify(stableConfig))
.digest('hex');
}

View file

@ -13,6 +13,9 @@ import type { LspCodeActionKind, LspDiagnosticSeverity } from './types.js';
/** Default timeout for LSP server startup in milliseconds */
export const DEFAULT_LSP_STARTUP_TIMEOUT_MS = 10000;
/** Default timeout for graceful LSP server shutdown in milliseconds */
export const DEFAULT_LSP_SHUTDOWN_TIMEOUT_MS = 5000;
/** Default timeout for LSP requests in milliseconds */
export const DEFAULT_LSP_REQUEST_TIMEOUT_MS = 15000;

View file

@ -254,6 +254,26 @@ export interface LspServerStatusInfo {
error?: string;
}
export type LspServerSkipReason = 'server_trust_required';
export interface LspSkippedServer {
name: string;
reason: LspServerSkipReason;
}
export interface LspReconcileResult {
added: string[];
removed: string[];
restarted: string[];
unchanged: string[];
failed: string[];
}
export interface LspServiceReinitializeResult {
reconcile: LspReconcileResult;
skipped: LspSkippedServer[];
}
export interface LspClient {
/**
* Get the status of all configured LSP servers.
@ -376,6 +396,11 @@ export interface LspClient {
* Get a point-in-time snapshot of LSP server connection status.
*/
getStatusSnapshot?(): LspStatusSnapshot;
/**
* Re-read LSP configuration and reconcile live server connections.
*/
reinitialize?(): Promise<LspServiceReinitializeResult>;
}
// ============================================================================
@ -586,6 +611,10 @@ export interface LspServerHandle {
processDiagnostics?: LspProcessDiagnostics;
/** Lock to prevent concurrent startup attempts */
startingPromise?: Promise<void>;
/** Cancels an in-flight startup attempt */
startupAbortController?: AbortController;
/** Whether the owned process exited unexpectedly during the current startup */
processExitedUnexpectedly?: boolean;
}
/**