mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
802c382ce1
commit
3bf0fa0af0
21 changed files with 5746 additions and 86 deletions
73
docs/design/hot-reload/README.md
Normal file
73
docs/design/hot-reload/README.md
Normal 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.
|
||||
658
docs/design/hot-reload/lsp-runtime-reinitialization.md
Normal file
658
docs/design/hot-reload/lsp-runtime-reinitialization.md
Normal 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`.
|
||||
393
packages/cli/src/config/lsp-config-watcher.test.ts
Normal file
393
packages/cli/src/config/lsp-config-watcher.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
303
packages/cli/src/config/lsp-config-watcher.ts
Normal file
303
packages/cli/src/config/lsp-config-watcher.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
|
|
|
|||
|
|
@ -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(',');
|
||||
}
|
||||
|
|
|
|||
66
packages/core/src/lsp/configHash.test.ts
Normal file
66
packages/core/src/lsp/configHash.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
49
packages/core/src/lsp/configHash.ts
Normal file
49
packages/core/src/lsp/configHash.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue