LSPランタイムホットリロード設計
背景
本設計は、mcp-runtime-reinitialization.md で使用されているのと同じレイヤリングに従います。つまり、CLIがリロードのトリガーを決定し、Coreがランタイム状態の更新方法を決定します。また、settings-change-detection.md のウォッチャー原則(起動時のファイルシステムへの副作用なし、デバウンスされた変更、セマンティックdiff、直列化されたリスナー、およびメインセッションに影響を与えないリスナーの失敗)も再利用します。
LSPとMCPの主な違いは、LSPサーバーの設定が settings.json に存在しないことです。現在、ネイティブLSPサービスは LspConfigLoader を使用してワークスペースの .lsp.json と有効化された拡張機能の lspServers 宣言を読み取り、その結果を NativeLspService.discoverAndPrepare() 経由でシングルセッションの LspServerManager に書き込み、最後に start() で設定されたすべてのサーバーを起動します。したがって、SettingsWatcher 単体ではワークスペースの .lsp.json の変更を検出できません。
現在のコード評価
- LSPの起動は、
packages/cli/src/config/config.tsの--experimental-lspによってのみ制御されます。現在、--allowed-lsp-server-namesフラグや同等のLSP CLI許可リストパラメータは存在しません。既存の--allowed-mcp-server-namesフラグはMCP専用です。 NativeLspServiceはCLI設定の読み込み中に一度だけ構築されます。起動パスはdiscoverAndPrepare()を呼び出し、次にstart()を呼び出し、その後サービスをNativeLspClientでラップしてConfigにアタッチします。Config.setLspClient()とConfig.setLspInitializationError()は現在、初期化後にスローされるため、ランタイムホットリロードはクライアントオブジェクトを置き換えるべきではありません。既存のNativeLspClientを保持し、その背後にあるサービスのインクリメンタルなreconcileのみを行うべきです。LspConfigLoaderはワークスペースの.lsp.jsonとアクティブな拡張機能のlspServersのみを読み取ります。ワークスペースの.lsp.jsonは、サーバー名ごとに拡張機能の設定をオーバーライドします。LspServerManager.setServerConfigs()は現在すべてのハンドルをクリアします。インクリメンタルなreconcileはまだサポートされていません。- 現在のリポジトリにはLSP用の共有プールパスがありません。各セッションは独自の
NativeLspServiceとサブプロセス/ソケット接続を所有します。設計では将来の共有プールに向けた境界を設けるべきですが、v1ではシングルセッションモードのみを実装します。
目標
現在の Qwen Code セッションを再起動せずに、LSPサーバー設定の変更を反映させます。
- サーバーが追加されたときに起動する。
- サーバーが削除されたときに停止し、ステータスとツールルーティングから削除する。
- 設定が変更された場合、変更されたサーバーのみを再起動する。
- 変更されていないサーバーは接続を維持し、ウォームアップ状態を保持する。
- 信頼されていない、または許可されていないサーバーは絶対に起動しない。
- LSPツールと
/lspステータスが、既存のクライアントオブジェクトを通じて新しいランタイム状態を監視できるようにする。
非目標
- 本変更で共有LSPプロセスプールは追加しない。
- ランタイムでの
--experimental-lspの切り替えはサポートしない。起動時にLSPが有効になっていない場合、リロード対象のサービスは存在しない。 lspServersに影響を与える拡張機能のインストール/アンインストールの変更を完全にウォッチしない。拡張機能の設定変更は手動の/reloadでカバーする。
設計
1. 安定したハッシュで各LSPサーバーを識別する
LSP設定コードの近くに小さなヘルパーを追加します。
export function lspServerConfigHash(config: LspServerConfig): string;ハッシュは安定しており、LspConfigLoader によって生成された正規化されたランタイム設定に基づく必要があります。
namelanguagestransportcommandargsenvinitializationOptionssettingsextensionToLanguageworkspaceFolderrootUristartupTimeoutshutdownTimeoutrestartOnCrashmaxRestartstrustRequiredsocket
オブジェクトキーは、JSONのプロパティ順序によって不要な再起動が発生しないようにソートする必要があります。コマンド引数の順序と言語の優先順位は意味を持つ可能性があるため、配列の順序は維持されます。プロセスID、ステータス、再起動カウント、診断、またはウォームアップ状態などのランタイムフィールドは含めないでください。
将来の共有プールとの互換性のため、プールIDを次のように定義します。
lsp:<workspaceRoot>:<serverName>:<configHash>v1のシングルセッションマネージャーは serverName -> configHash を維持するだけで十分ですが、同じハッシュは後でプールキーで直接再利用できます。
2. LspServerManager にインクリメンタルなreconcileを追加する
ホットリロードでは、すべてのハンドルをクリアする setServerConfigs() を再利用すべきではありません。以下を追加します。
async reconcileServerConfigs(
configs: LspServerConfig[],
): Promise<LspReconcileResult>フロー:
- 目的のマップを構築します:
name -> configとname -> hash。 - サーバーが存在しなくなった既存のハンドルについては、既存の
stopServer()を呼び出し、その後ハンドルを削除します。 - ハッシュが変更された既存のハンドルについては、
stopServer()を呼び出し、ハンドルを{ config, status: 'NOT_STARTED' }に置き換えてから起動します。 - 新しいサーバーについては、
{ config, status: 'NOT_STARTED' }を作成して起動します。 - ハッシュが変更されていないサーバーについては、何もしずに既存のハンドルを保持します。
プライベートフィールドを追加します。
private serverConfigHashes = new Map<string, string>();stopAll() と clearServerHandles() でクリアします。
戻り値:
interface LspReconcileResult {
added: string[];
removed: string[];
restarted: string[];
unchanged: string[];
failed: string[];
}skipped は LspServerManager の結果には含まれません。マネージャーはadmission(受け入れ)を通過した設定のみを処理し、admissionで拒否されたサーバーは NativeLspService.reinitialize() によってサービスレベルの結果に集約されます。
並行性:
LspServerManagerまたはNativeLspServiceのいずれかにreconcileキューを追加し、reconcileが直列に実行されるようにします。同じプロセスの停止と開始で競合が発生してはなりません。- サーバーがまだ起動中のときに新しい設定が届いた場合は、停止する前に
handle.startingPromiseを待機します。サーバーごとの追加ロックを追加する代わりに、既存の起動ロックを再利用します。 stopServer()自体は、stopRequestedを設定した後にhandle.startingPromiseをawaitする必要があります。これにより、stopAll()、削除、および再起動パスはすべて、まだ接続/プロセスを割り当て中のクラッシュ再起動をカバーします。
失敗時の動作:
- 新規追加または変更されたサーバーの起動に失敗した場合は、ハンドルを保持し、
/lspが失敗を説明できるようにFAILEDとしてマークします。 - 起動の失敗を
addedまたはrestartedとしてカウントせず、failedで報告します。 - 起動に失敗した場合、設定ハッシュをキャッシュしないでください。同じ設定での後続の保存は、
unchangedに分類されるのではなく、再試行される必要があります。 - 接続またはプロセスが作成された後に起動が失敗した場合は、戻る前にその接続/プロセスを解放してください。失敗した初期化により、
FAILEDハンドルの背後に言語サーバープロセスやソケット接続が生きたまま残ってはいけません。 - 信頼の拒否、安全でないコマンドパス、またはコマンドの欠落など、接続作成前に起動が失敗した場合は、キャッシュされた設定ハッシュをクリアします。同じ設定での後続のreconcileは、失敗したハンドルをunchangedとして扱うのではなく、再試行する必要があります。
- 削除されたサーバーがシャットダウン中にエラーをログに出力した場合でも、ハンドルマップから削除します。
- 1つのサーバーの起動失敗が、他のサーバーのreconcileをブロックしてはなりません。
リソースのクリーンアップ:
stopServer()は、所有されたサーバーの両側を解放する必要があります。LSP接続をグレースフルにシャットダウンして終了し、その後、生成されたプロセスがまだ稼働中であればkillします。これはcommandで起動されたtcp/socketトランスポートにとって重要です。ソケットを閉じるだけでは不十分です。process.kill()は独自のエラーハンドリングで分離する必要があります。クリーンアップ中に終了したプロセスが、reconcileの残りの部分を中止してはなりません。- グレースフルシャットダウンには常に境界のある待機時間を設ける必要があります。サーバー設定で
shutdownTimeoutが指定されていない場合は、connection.shutdown()を永遠にawaitするのではなく、デフォルトのシャットダウンタイムアウトを使用します。 - 大きなタイムアウトがハンドルを必要以上に長く保持しないように、シャットダウンが完了または失敗したときにシャットダウンタイムアウトタイマーをクリアする必要があります。
- タイムアウトが競合に勝った場合でも、基礎となる
shutdown()promiseを監視する必要があります。これにより、遅延したサーバー側の拒否が未処理の拒否として表面化することを防ぎます。 stopAll()は、ホットリロードと同じreconcileキューに参加する必要があります。現在のキューを待機してからハンドルを反復処理するだけでは不十分です。待機とハンドルクリーンアップの間に新しいreconcileが進入する可能性があるためです。NativeLspService.stop()は、サーバーを停止する前に、実行中またはキューイングされているreinitialize()操作もキャンセルする必要があります。実装ではAbortControllerを使用した協調的キャンセルを使用します。stopはサービスを停止中としてマークし、アクティブなリロードを中止します。また、キューイングされた各リロードは、設定の読み込み、reconcile、ドキュメントトラッキングのクリア、ドキュメントの再生、または再生遅延の待機を行う前にシグナルをチェックします。これにより、シャットダウンが遅いリロードで無期限にブロックされるのを防ぎ、同時にキャンセルされたリロードがstopAll()後に新しいLSPプロセスを起動するのを防ぎます。- クラッシュ再起動もreconcileキューを通じて直列化されるか、永久に失敗したときにハッシュをクリアする必要があります。設定変更のreconcileと並行して代替プロセスを開始してはなりません。
- クラッシュ再起動のリセットは、
connection.end()とprocess.kill()のエラーを分離する必要があります。リセットは、古い接続/プロセスがすでに壊れている可能性がある場合に実行され、クリーンアップの失敗がキューイングされた再起動の継続を妨げてはなりません。 NativeLspService.stop()は、serverManager.stopAll()の後にopenedDocumentsとlastConnectionsをクリアし、停止されたサービスが古いドキュメントセットや接続オブジェクトを保持しないようにする必要があります。
3. NativeLspService.reinitialize() を追加する
以下を追加します。
async reinitialize(): Promise<LspServiceReinitializeResult>フロー:
requireTrustedWorkspaceがtrueで!config.isTrustedFolder()の場合、serverManager.stopAll()を呼び出してreturnします。これにより、ワークスペースが信頼されなくなった後に古いLSPプロセスが継続するのを防ぎます。- 既存の
LspConfigLoaderを使用して、ワークスペースの.lsp.jsonと拡張機能の設定を読み込みます。 - 現在の優先順位を使用して設定をマージします。
- reconcileの前にLSP admissionフィルターを適用します。
serverManager.reconcileServerConfigs(serverConfigs)を呼び出します。- 削除されたサーバーと正常に再起動されたサーバーについてのみ
openedDocumentsとlastConnectionsをクリアし、変更されていないサーバーと失敗したサーバーのドキュメント状態は保持します。失敗したサーバーはドキュメントトラッキングを保持するため、後で正常に再起動されたときに同じ開いているドキュメントを再生できます。 - 正常に再起動されたサーバーについて、再起動前に開いていたドキュメントの
textDocument/didOpenを再生します。これにより、次のホバー、補完、または診断リクエストで各ファイルが遅延再オープンされるのを待つことなく、代替サーバーに同じドキュメントコンテキストを提供します。サーバーに対して1つ以上のドキュメントを再生した後、リロードの完了を報告する前に、遅延ensureDocumentOpen()で使用されるものと同じドキュメントオープン遅延を待機します。
開いているドキュメントのスナップショットは、reconcileServerConfigs() が返された後に取得され、reconcile.restarted にスコープ限定される必要があります。reconcileが保留中に開かれたドキュメントは、再起動されたサーバーのトラッキングがクリアされる前に、再生スナップショットに含まれます。
初期検出では、setServerConfigs() を呼び出す前に同じadmissionフィルターを使用する必要があります。これにより、信頼されていないワークスペースでのサーバーごとの trustRequired フィルタリングについて、起動時とホットリロード時のステータスが一貫して保たれます。
.lsp.json の解析失敗には特別な処理が必要です。解析失敗を空の設定として扱わないでください。ウォッチャーはinvalid-configイベントを報告し、CLIがユーザーに表示可能なエラーを表示できるようにする必要がありますが、そのイベントに対して reinitialize() を呼び出してはなりません。reinitialize() は古いランタイム状態を保持し、reconcileをスキップし、エラーをステータス/ログに書き込む必要があります。ファイルを削除した場合、または有効な空のJSON設定を解析した場合にのみ、目的の設定が空であるとみなされます。
コールドスタートアップとホットリロードでは、意図的に異なるユーザー設定の解析厳格性を使用します。
loadUserConfigs()は、起動時の互換性のために寛容なままにします。無効なサーバーエントリをスキップし、構築可能な有効なエントリを返します。loadUserConfigsStrict()はホットリロードで使用されます。既存の.lsp.jsonが構文的に有効であっても、無効なトップレベルの形状や構築できないサーバーエントリが含まれている場合、エラーを返し、reinitialize()はreconcileを行いません。これにより、無効な編集に対して現在実行中のLSP状態が保持されます。厳密なパスでは、コールドスタートアップでも強制されていないフィールドレベルのバリデーションを導入してはなりません。そのような場合、起動時には有効だが次回の保存時には無効になる設定が発生してしまうためです。既知のフィールドのバリデーションの厳格化は、起動時とホットリロードの両方に対して、別の互換性に関する決定として処理されるべきです。厳密な読み込み中にファイルが存在しないか削除された場合は、そのENOENTを有効な空のユーザー設定として扱います。.lsp.jsonを削除することは、ワークスペースのユーザーLSPサーバーをすべて削除するための明示的な方法であるためです。NativeLspService.reinitialize()はサービスレベルの結果を返します。
interface LspServiceReinitializeResult {
reconcile: LspReconcileResult;
skipped: Array<{
name: string;
reason: 'server_trust_required';
}>;
}NativeLspClient にオプションの reinitialize() メソッドを追加し、サービスに委譲します。Config.reinitializeLsp() で不透明な型アサーションを避けるため、LspClient インターフェースを直接拡張します。
reinitialize?: () => Promise<LspServiceReinitializeResult>;Config に以下を追加します。
async reinitializeLsp(): Promise<LspServiceReinitializeResult | undefined>LSP が無効になっている場合、またはクライアントが存在しない場合、これは no-op となります。このメソッドは Config.initialize() 後にクライアントを置き換えてはなりません。
setLspInitializationError() は現在、初期化後の呼び出しを拒否するため、ランタイムで安全なプライベートな状態セッターを追加します。
private setRuntimeLspInitializationError(error: Error | string | undefined): voidreinitializeLsp() はこれを使用して、初期化後のクライアント変更 API を緩めることなく、getLspStatusSnapshot() を通じてリロードの失敗を公開します。failed サーバーを含む返された reconcile 結果は、完全な成功ではなく部分的な失敗です。reinitializeLsp() はその場合に initializationError を設定し、リロードで失敗したサーバーがない場合にのみエラーをクリアする必要があります。
4. 受け入れと権限の境界
現在の LSP の安全チェックには以下のものが含まれます。
--experimental-lspが唯一の有効化スイッチです。- ワークスペースの信頼性は、検出/起動前にチェックされます。
- 各サーバーの
trustRequiredはデフォルトで true です。 - コマンドの存在とコマンドパスの安全性は、spawn 前にチェックされます。
workspaceFolderはワークスペースのルートに制限されます。
ホットリロードはこれらのチェックを維持し、新しいサーバーを起動する前、または変更されたサーバーを再起動する前に完了させる必要があります。重要なルールは、先に spawn してからサーバーが許可されるかどうかを後で決定しないことです。
ワークスペースの .lsp.json はワークスペースが制御する入力です。したがって、ファイル内で明示的に "trustRequired": false と宣言されている場合でも、ユーザー設定は常に trustRequired: true として扱う必要があります。拡張機能によって提供される LSP 設定は、宣言された trustRequired の値を引き続き使用できます。これにより、信頼されていないワークスペースが自身の信頼境界を低下させるのを防ぎます。
.lsp.json からの環境変数もワークスペースによって制御されます。ランタイムの spawn は許可された環境変数のオーバーライドをマージする場合がありますが、NODE_OPTIONS、LD_PRELOAD、LD_LIBRARY_PATH、DYLD_INSERT_LIBRARIES、DYLD_LIBRARY_PATH などのコードインジェクション変数は、LSP 設定によってオーバーライドされてはなりません。一般的なツールチェーンのセットアップを維持するため、実際のサーバープロセスには PATH が許可されます。コマンド存在のプロービングは、プローブが必要とする可能性のある通常の設定提供環境変数を保持する場合がありますが、裸のコマンド名を解決する際に設定提供の PATH を使用してはなりません。これにより、悪意のあるワークスペースの PATH が、実際の起動パスの前に clangd --version などのプローブを意図しない実行ファイルにリダイレクトするのを防ぎます。プローブ専用の PATH フィルターを含む機密環境キーのフィルタリングは、Path、node_options、Ld_PreLoad などの Windows スタイルの大文字と小文字を区別しない環境名が拒否リストを回避できないように、大文字と小文字を区別しない必要があります。
許可リストの境界:
- 現在のリポジトリは、LSP サーバー名の CLI 許可リストをサポートしていません。LSP には
--experimental-lspしかないことを確認しました。許可リストのパラメータは MCP 専用です。 - この機能で
--allowed-lsp-server-namesが追加される場合、それは MCP の起動許可リストと同じように動作し、セッションの存続期間全体の上限として機能する必要があります。ランタイム設定はこのセットを狭めることができますが、CLI 起動の上限を超えて拡張してはなりません。 - 起動の上限を
ConfigParameters.lspに保存します。
cliAllowedLspServerNames?: string[];それに対する getter を公開します。変更可能な設定から上限を読み取らないでください。
受け入れ(Admission)は純粋な関数に抽出する必要があります。
filterLspServerConfigs(configs, {
workspaceTrusted,
requireTrustedWorkspace,
cliAllowedServerNames,
}): {
admitted: LspServerConfig[];
skipped: Array<{
name: string;
reason: 'server_trust_required';
}>;
}現在 LSP の承認ストアや CLI 許可リストはありませんが、このヘルパーはセキュリティ境界を明示的にし、将来のハッシュベースの承認ゲートに向けた余地を残します。将来 --allowed-lsp-server-names フラグが追加された場合、v1 で未接続の許可リストパスを引きずるのではなく、その時点で not_allowed のスキップ理由を追加する必要があります。
信頼のセマンティクスは、現在の起動パスと一致する必要があります。
requireTrustedWorkspaceが true でワークスペースが信頼されていない場合、NativeLspService.reinitialize()はサービス層ですべてのサーバーを停止して戻ります。受け入れフィルターに入らず、古いサーバーも保持しません。requireTrustedWorkspaceが false の場合、サービスはグローバルにショートカットしませんが、受け入れフィルターは引き続きtrustRequired: trueの個々のサーバーをスキップします。- ワークスペースが信頼されている場合、
trustRequiredはサーバーをブロックしません。
5. トリガー
2 つのトリガーパスが必要です。
自動ワークスペース .lsp.json トリガー
CLI に、SettingsWatcher をモデルにした、より責任の小さい狭い LspConfigWatcher を追加します。
- ワークスペースのルートのみを監視し、ベース名
.lsp.jsonに厳密に一致させます。 - ディレクトリやファイルは一切作成しません。
- 300 ms のデバウンスを行います。
- フォーマットのみの変更がリロードをトリガーしないように、parse + canonicalize を使用して
.lsp.jsonの前後を比較します。 ENOENTは削除として扱います。- JSON の解析失敗とその他の読み取り失敗を区別します。どちらの場合も、ユーザーが確認できる無効な設定イベントでリスナーに通知し、古いランタイム状態を保持する必要がありますが、エラーメッセージはファイルが不正な JSON だったか、読み取り不能だったかを反映する必要があります。
- ファイルの削除は別のイベントであり、リロードリスナーに通知して空のワークスペース設定を生成する必要があります。
- コールバックは直列に実行します。
SettingsWatcherに合わせたリスナーのタイムアウトと失敗の分離を使用します。- 格納されているセマンティックスナップショットの更新は、リスナーへの通知が成功した後にのみ行います。リスナーがスローまたはタイムアウトした場合は、以前のスナップショットを保持し、同じ内容を再度保存することでリロードが再試行されるようにします。
config.isLspEnabled() であり、かつクライアントが reinitialize() をサポートしている場合にのみウォッチャーを登録します。変更時には、以下を呼び出します。
await config.reinitializeLsp();次に、AppEvent.LspStatusChanged などの明示的なランタイムイベントを発行します。/lsp、/about、/status などの UI サーフェスは、そのイベントをサブスクライブして更新できます。reconcile が部分的な失敗を返した場合、ウォッチャーにスローバックする前に status-changed イベントを発行します。これにより、UI は正常に再起動されたサーバーを観察でき、ウォッチャーは再試行のために古いセマンティックスナップショットを保持したままになります。失敗時には、AppEvent.LogError を通じてユーザーが確認できるエラーも表示します。利用可能な場合は基礎となるパーサー/起動エラーメッセージを含め、デバッグログの書き込みのみにとどめないでください。
手動 /reload トリガー
将来 /reload コマンドが実装されたら、以下の両方を呼び出す必要があります。
await config.reinitializeMcpServers(...);
await config.reinitializeLsp();手動リロードは、拡張機能の lspServers の変更に対するフォールバックパスも提供します。なぜなら、それらの変更はワークスペースの .lsp.json ファイルイベントにマッピングされない可能性があるためです。
シングルセッションと共有プール
現在の状態: シングルセッションモードのみが存在します。リポジトリには MCP トランスポートプールに相当する LSP の機能はありません。
v1: LspServerManager 内でインクリメンタルな reconcile を実装します。各セッションは独自のプロセスとソケットを所有します。
将来の共有プール: NativeLspService をコンシューマーとして保持し、LspServerManager の内部を以下の acquire/release で置き換えます。
lsp:<workspaceRoot>:<name>:<hash>プールエントリ。MCP 共有プールの修正に合わせて、acquire の前に引き続き受け入れフィルタリングを行う必要があります。これにより、許可されていないサーバーや信頼されていないサーバーがプールパスを通じて起動されるのを防ぎます。
単体テスト計画
単体テストを優先します。実際の LSP サーバーに対する統合テストは遅く、環境に依存するため、必須ではありません。
コアテスト
packages/core/src/lsp/configHash.test.ts
- ハッシュはオブジェクトのキー順序を無視します。
- command、args の順序、env、settings、workspace folder、socket、および trust requirement の変更はハッシュを変更します。
- ハッシュは status/process/runtime フィールドを除外します。
packages/core/src/lsp/LspServerManager.test.ts
- サーバーの追加はちょうど 1 回だけ起動します。
- サーバーの削除はそれをシャットダウンし、ハンドルから削除します。
- ハッシュの変更は古いハンドルを停止し、新しいハンドルを起動します。
- 変更されていないハッシュは停止/起動を行わず、ハンドルの ID を保持します。
- 接続作成後の起動失敗は、接続と所有プロセスを解放します。
commandによって起動されたtcp/socketサーバーの停止は、接続を閉じ、所有プロセスを kill します。- シャットダウンが先に完了した場合、シャットダウンのタイムアウトタイマーはクリアされます。
shutdownTimeoutが欠落している場合でもデフォルトのシャットダウンタイムアウトが使用され、reconcile が永久にブロックされることはありません。stopAll()はリソースを解放する前に、進行中の起動を待ちます。stopAll()は reconcile キューを通じて直列化され、その後の reconcile と同時に実行されることはありません。process.kill()のエラーはログに記録され、クリーンアップを中止しません。- 1 つのサーバーの起動失敗は、別のサーバーの reconcile に影響を与えません。
- 並行する reconcile は直列に実行されます。
stopAll()とclearServerHandles()はハッシュマップをクリアします。- 失敗した起動は
failedで報告され、added/restarted としては報告されず、設定ハッシュをキャッシュしません。 - 初期起動の失敗はキャッシュされたハッシュをクリアするため、同じ設定での後の reconcile が再試行されます。
- クラッシュ時の再起動は reconcile と直列化され、恒久的な失敗時にキャッシュされたハッシュをクリアします。
- クラッシュ時の再起動リセットは、接続/プロセスのクリーンアップエラーを無視し、キューに入れられた再起動を続行します。
- コマンド存在のプロービングは通常の設定提供環境変数を保持しますが、設定提供の
PATHは使用せず、コードインジェクション環境のオーバーライドは spawn 前にフィルタリングされます。 - reconcile の戻り値には added/removed/restarted/unchanged/failed が含まれ、admission skipped は含まれません。
テストでは createLspConnection、初期化、およびシャットダウンをモックします。実際の言語サーバーは起動しないでください。
packages/core/src/lsp/NativeLspService.test.ts
reinitialize()はワークスペースと拡張機能の設定をロードし、マージされた設定をマネージャーの reconcile に渡します。.lsp.jsonの解析失敗は古いランタイム状態を保持し、マネージャーの reconcile を呼び出しません。- 厳格なホットリロードは、reconcile せずに構築できない無効なトップレベルの形状とサーバーエントリを拒否しますが、コールドスタートアップは同じファイルから有効なエントリのロードを継続します。
.lsp.jsonの削除はワークスペース設定を空として扱い、reconcile をトリガーします。- 厳格なローディングは
ENOENTを空のユーザー設定として扱います。これには、ウォッチャーの通知とリロードの間にファイルが消える削除競合も含まれます。 - 信頼されていないワークスペースはすべてのサーバーを停止し、reconcile/起動を行いません。
- 初期検出は、ホットリロードと同じサーバーごとの
trustRequired受け入れフィルターを適用します。 - ワークスペースの
.lsp.jsonはtrustRequiredをオプトアウトできません。 - CLI 許可リストが実装されている場合、上限が許可された設定をフィルタリングします。
- サービスレベルの戻り値は、スキップされた受け入れの理由を集約します。
- 再起動/削除されたサーバーは、独自のドキュメント追跡のみをクリアします。
- 失敗したサーバーはドキュメント追跡をクリアせず、後で正常に再起動した後にそれらのドキュメントをリプレイできます。
- 再起動されたサーバーは、置換サーバーの準備ができた後に、以前に開かれたドキュメントの
textDocument/didOpenをリプレイし、その後ドキュメントオープン処理の遅延を待ちます。 - reconcile が保留中に開かれたドキュメントは、再起動されたサーバーのリプレイスナップショットに含まれます。
stop()は、新しいサーバーを起動する前に、進行中のリプレイ遅延とキューに入れられたreinitialize()呼び出しをキャンセルします。stop()は、すべてのサーバーを停止した後にドキュメント追跡キャッシュをクリアします。
packages/core/src/config/config.test.ts
reinitializeLsp()は、無効になっている場合やクライアントが存在しない場合は no-op になります。- 有効でクライアントが
reinitializeをサポートしている場合、呼び出しを委譲します。 - reinitialize がスローされた場合、ステータススナップショットは初期化/リロードエラーを公開します。
- reinitialize が部分的な失敗を返した場合、ステータススナップショットは、後で完全に成功したリロードによってクリアされるまで、初期化/リロードエラーを公開します。
CLI テスト
packages/cli/src/config/lspConfigWatcher.test.ts
.lsp.jsonを作成しないこと。- 作成/変更/削除を検出すること。
- 無関係なファイルを無視すること。
- 正規化パース後のフォーマットのみの変更を無視すること。
- パース失敗時にユーザーに表示される無効な設定の通知を発行し、 LSP の再初期化をトリガーしないこと。
- ENOENT 以外の読み取り失敗時にユーザーに表示される読み取り失敗メッセージを発行し、 LSP の再初期化をトリガーしないこと。
.lsp.jsonの削除がリロードリスナーをトリガーすること。- 重複するファイルイベントがデバウンスされること。
- 遅いリスナーが直列に実行されること。
- リスナーの失敗時に保存されたスナップショットが更新されず、同じコンテンツが 後の通知で再試行できること。
packages/cli/src/ui/AppContainer.test.tsx または対応するイベントテスト
AppEvent.LspStatusChangedが UI のリフレッシュをトリガーすること。- リロード失敗時に
AppEvent.LogErrorを通じてユーザーに表示されるエラーを発行すること。 - 部分的なリコンサイル失敗時でも、リスナーが拒否する前に
AppEvent.LspStatusChangedを発行し、UI 状態がリロードの成功した部分を反映できるようにすること。
packages/cli/src/config/config.test.ts
--experimental-lspがネイティブ LSP を構築して開始するという既存のアサーションを維持すること。--allowed-lsp-server-namesが追加された場合、パーサーがカンマ区切りの値と繰り返しフラグをサポートし、それらを起動時の上限として保存すること。
packages/cli/src/ui/commands/lspCommand.test.ts
LspStatusSnapshotがスキップされた理由を公開する場合、ステータス出力にスキップ/許可されていないサーバーを表示できること。
カバレッジ目標: 新しい純粋関数は 100% に近いこと。ウォッチャーの分岐カバレッジは
SettingsWatcher と同等であること。マネージャーのリコンサイルは
追加/削除/変更/変更なし/失敗/並行性をカバーすること。
厳格なレビュー
結論
-
v1 では stop-all/start-all を使用すべきではない。 その実装が最もシンプルだが、保存のたびに未変更の言語サーバーが再起動され、 ウォーム状態が失われてしまう。現在のマネージャーにはすでにサーバーごとの ライフサイクルメソッドがあり、インクリメンタルなリコンサイルは管理可能な 追加コード量である。
-
.lsp.jsonの変更をSettingsWatcherに含めないこと。SettingsWatcherは設定スコープのリロードを担当する。これが任意のワークスペースファイルを監視するようにすると、契約が曖昧になり、MCP/設定の動作について推論するのが難しくなる。独立した、狭い範囲の.lsp.jsonウォッチャーの方が明確である。 -
初期化後に
NativeLspClientを置き換えないこと。Config.setLspClient()は初期化後の変更を明示的に禁止している。アダプターの背後にあるサービスを更新することで、ライフサイクル API の拡張を回避できる。 -
許可(Admission)はプロセスの生成またはプールの取得より前に行わなければならない。 これは MCP 共有プール設計で指摘されたのと同じリスクである。現在 LSP にはプールがないが、サービスレベルのリロード結果は、将来のプールパスが誤って拒否されたサーバーを起動しないように、起動前のフィルタリングでスキップされた理由を返すべきである。
-
新しい LSP CLI 許可リストは任意だが、追加する場合は上限で なければならない。 現在のコードには LSP 許可リストがない。設計上、設定が実行時にコマンドラインの 制限を拡大できないようにしなければならない。そうでなければ、MCP の ホットリロードセキュリティセマンティクスよりも弱くなってしまう。
残存リスク
- 拡張機能の
lspServersは.lsp.jsonが変更されなくても変更される可能性がある。自動ウォッチャーはすべての拡張機能のファイルシステム変更をカバーするわけではなく、手動の/reloadがそのパスをカバーする。 - 一部の言語サーバーは急速な再起動にうまく対応できない。直列化されたリコンサイルとデバウンスによりリスクは軽減されるが、テストでは高速な連続変更をカバーすべきである。
- TCP/ソケットサーバーは外部で管理されるデーモンである可能性がある。リコンサイルは接続を閉じるべきだが、このプロセスが
command経由でサーバーを生成した場合にのみ、プロセスの所有権を引き受けるべきである。