docs/design/hot-reload/lsp-runtime-reinitialization.md
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.
--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.NativeLspService and subprocess/socket connections. The design should
leave a boundary for a future shared pool, but v1 only implements
single-session mode.Make LSP server configuration changes take effect without restarting the current Qwen Code session:
/lsp status observe the new runtime state through the
existing client object.--experimental-lsp at runtime. If LSP was not
enabled at startup, there is no service to reload.lspServers; manual /reload will cover extension config changes.Add a small helper near the LSP config code:
export function lspServerConfigHash(config: LspServerConfig): string;
The hash must be stable and based on the normalized runtime config produced by
LspConfigLoader:
namelanguagestransportcommandargsenvinitializationOptionssettingsextensionToLanguageworkspaceFolderrootUristartupTimeoutshutdownTimeoutrestartOnCrashmaxRestartstrustRequiredsocketObject 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:
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.
LspServerManagerHot reload should not reuse setServerConfigs(), which clears every handle.
Add:
async reconcileServerConfigs(
configs: LspServerConfig[],
): Promise<LspReconcileResult>
Flow:
name -> config and name -> hash.stopServer(), then delete the handle.stopServer(), replace the
handle with { config, status: 'NOT_STARTED' }, then start it.{ config, status: 'NOT_STARTED' } and start them.Add a private field:
private serverConfigHashes = new Map<string, string>();
Clear it in stopAll() and clearServerHandles().
Return:
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:
LspServerManager or NativeLspService so
reconciles run serially. Stopping and starting the same process must not race.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:
FAILED so /lsp can explain the failure.added or restarted; report it in
failed.unchanged.FAILED handle.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.shutdownTimeout, use the default shutdown timeout instead of
awaiting connection.shutdown() forever.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().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.NativeLspService.reinitialize()Add:
async reinitialize(): Promise<LspServiceReinitializeResult>
Flow:
requireTrustedWorkspace is true and !config.isTrustedFolder(), call
serverManager.stopAll() and return. This prevents old LSP processes from
continuing after the workspace becomes untrusted.LspConfigLoader to load the workspace .lsp.json and
extension configs.serverManager.reconcileServerConfigs(serverConfigs).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.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:
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:
reinitialize?: () => Promise<LspServiceReinitializeResult>;
Add to Config:
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:
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.
Current LSP safety checks include:
--experimental-lsp is the only enablement switch;trustRequired defaults to true;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:
--experimental-lsp; allow-list parameters are
MCP-only.--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.ConfigParameters.lsp:cliAllowedLspServerNames?: string[];
Expose a getter for it. Do not read the upper bound from mutable settings.
Admission should be extracted into a pure function:
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:
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.requireTrustedWorkspace is false, the service does not short-circuit
globally, but the admission filter still skips individual servers with
trustRequired: true.trustRequired does not block the server.Two trigger paths are needed.
.lsp.json TriggerAdd a narrow LspConfigWatcher in the CLI, modeled after SettingsWatcher but
with a smaller responsibility:
.lsp.json;.lsp.json before/after using parse + canonicalize so formatting-only
changes do not trigger reload;ENOENT as deletion;SettingsWatcher;Register the watcher only when config.isLspEnabled() and the client supports
reinitialize(). On change, call:
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.
/reload TriggerWhen the future /reload command lands, it should call both:
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.
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:
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.
Prioritize unit tests. Integration tests against real LSP servers are slow and environment-dependent, so they are not required.
packages/core/src/lsp/configHash.test.ts
packages/core/src/lsp/LspServerManager.test.ts
tcp/socket server launched by command closes the connection
and kills the owned process;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;stopAll() and clearServerHandles() clear the hash map;failed, are not reported as added/restarted,
and do not cache their config hash;PATH, and code-injection env overrides are filtered
before spawn;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;.lsp.json treats workspace config as empty and triggers reconcile;ENOENT as an empty user config, including the
deletion race where the file disappears between watcher notification and
reload;trustRequired admission filter
as hot reload;.lsp.json cannot opt out of trustRequired;textDocument/didOpen for previously opened
documents after the replacement server is ready, then wait for the
document-open processing delay.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;reinitialize, it delegates the call;packages/cli/src/config/lspConfigWatcher.test.ts
.lsp.json;.lsp.json triggers the reload listener;packages/cli/src/ui/AppContainer.test.tsx or the corresponding event test
AppEvent.LspStatusChanged triggers UI refresh;AppEvent.LogError.AppEvent.LspStatusChanged before the
listener rejects, so UI state can reflect successful parts of the reload.packages/cli/src/config/config.test.ts
--experimental-lsp constructs and
starts native LSP;--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
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.
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.
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.
Do not replace NativeLspClient after initialization.
Config.setLspClient() explicitly forbids post-init mutation. Updating the
service behind the adapter avoids expanding the lifecycle API.
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.
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.
lspServers may change without .lsp.json changing. The automatic
watcher does not cover all extension filesystem changes; manual /reload
covers that path.command.