Visual Studio 拡張(VSIX)で Language Server Protocol(LSP)サーバーを「対象言語のファイルを開かずに」起動したい、かつファイルを開いたときに二重起動させたくない——この記事はその一点突破のための実装ガイドです。SLanguageClientBroker が見つからない、UnifiedLspClient が null になる等の定番エラーの原因と対処も、実装と同じ粒度で整理します。
問題の本質と最終形のイメージ
Visual Studio の LSP クライアントは通常、対象コンテンツタイプのバッファ(=その言語のファイル)が開かれたタイミングで起動トリガーが走ります。ここで拡張側が「起動時に手動ロード」すると、後続の自動トリガーと競合して LSP サーバーが二重起動しがちです。解決の肝は次の二点に集約されます。
- 起動の一意化:MEF 共有部品(Shared)で LSP サーバーの接続(
Connection)を一元管理し、どこから呼ばれても同じインスタンスを返す。 - 正しいサービス解決:
ILanguageClientBrokerは VS サービスではなく MEF サービス。IComponentModel経由で取得する。
前提とゴール
| 項目 | 内容 |
|---|---|
| 対象 | Visual Studio 2022 以降の VSIX(Managed 拡張) |
| 言語 | C#(拡張コード) / 任意の LSP サーバー(外部 exe) |
| 目的 | ファイル未オープンでも拡張読み込み時に LSP サーバーを起動し、二重起動を防止 |
| 副次目標 | サービス解決エラー(SLanguageClientBroker 未検出、UnifiedLspClient null 等)の回避 |
最小構成の全体像(3コンポーネント)
- コンテンツタイプ定義:
[ContentType]と拡張子マップで VS に「対象言語」を認識させる。 - 共有ホスト(Shared):LSP サーバーのプロセス起動と
Connectionを集中管理。 - ILanguageClient 実装:
ActivateAsyncで「共有ホスト」のConnectionを返す。
そして AsyncPackage の InitializeAsync で ILanguageClientBroker から明示ロード(手動起動)します。ファイルオープン時の自動ロードが来ても、共有ホストが同じ接続を返すため、実体は一度しか起動しません。
コード:コンテンツタイプと拡張子の定義
using Microsoft.VisualStudio.Utilities;
using System.ComponentModel.Composition;
namespace MyVsix.Lsp
{
public static class MyContentTypes
{
public const string Name = "myLang";
}
// コンテンツタイプ定義(必須)
[Export(typeof(ContentTypeDefinition))]
[Name(MyContentTypes.Name)]
[BaseDefinition("code")]
internal sealed class MyLanguageContentType : ContentTypeDefinition { }
// 拡張子マップ(例:.mylang で myLang を割当て)
[Export(typeof(FileExtensionToContentTypeDefinition))]
[ContentType(MyContentTypes.Name)]
[FileExtension(".mylang")]
internal sealed class MyLanguageFileExtension : FileExtensionToContentTypeDefinition { }
}
コード:LSP サーバー共有ホスト(二重起動の抑止核)
using Microsoft.VisualStudio.LanguageServer.Client;
using System;
using System.ComponentModel.Composition;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace MyVsix.Lsp
{
public interface IMyLspHost
{
Task GetOrStartAsync(CancellationToken token);
bool IsRunning { get; }
void Stop();
}
// MEF 共有(PartCreationPolicy.Shared)により拡張内でただ1つのインスタンスにする
[Export(typeof(IMyLspHost))]
[PartCreationPolicy(CreationPolicy.Shared)]
internal sealed class MyLspHost : IMyLspHost
{
private readonly SemaphoreSlim _gate = new(1, 1);
private Task<Connection> _startTask; // "一度だけ" 起動を保証
private Process _proc;
private volatile Connection _connection;
public bool IsRunning => _proc is not null && !_proc.HasExited;
public async Task<Connection> GetOrStartAsync(CancellationToken token)
{
if (_connection is not null && IsRunning) return _connection;
await _gate.WaitAsync(token).ConfigureAwait(false);
try
{
if (_connection is not null && IsRunning) return _connection;
// 二重起動を排他制御
_startTask ??= StartCoreAsync(token);
_connection = await _startTask.ConfigureAwait(false);
return _connection;
}
finally
{
_gate.Release();
}
}
public void Stop()
{
try
{
_proc?.Kill(entireProcessTree: true);
}
catch { /* no-op */ }
finally
{
_proc?.Dispose();
_proc = null;
_connection = null;
_startTask = null;
}
}
private async Task<Connection> StartCoreAsync(CancellationToken token)
{
// VSIX に同梱した LSP サーバー exe を解決
var baseDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
var exePath = Path.Combine(baseDir, "my-lsp.exe");
if (!File.Exists(exePath))
throw new FileNotFoundException($"LSP server not found: {exePath}");
var psi = new ProcessStartInfo
{
FileName = exePath,
Arguments = "--stdio",
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
_proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
_proc.Exited += (_, __) =>
{
// クラッシュ等で停止したら次回 GetOrStartAsync で再起動できるように解放
Stop();
};
if (!_proc.Start())
throw new InvalidOperationException("Failed to start LSP process.");
// 必要なら stderr ログを監視(開発時のデバッグ用)
_ = Task.Run(async () =>
{
try
{
string? line;
while ((line = await _proc.StandardError.ReadLineAsync().ConfigureAwait(false)) is not null)
{
System.Diagnostics.Debug.WriteLine($"[LSP] {line}");
}
}
catch { /* ignore */ }
}, token);
var conn = new Connection(_proc.StandardOutput.BaseStream, _proc.StandardInput.BaseStream);
return await Task.FromResult(conn);
}
}
}
コード:ILanguageClient 実装(共有ホストを使う)
using Microsoft.VisualStudio.LanguageServer.Client;
using Microsoft.VisualStudio.Utilities;
using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using System.Threading;
using System.Threading.Tasks;
namespace MyVsix.Lsp
{
[Export(typeof(ILanguageClient))]
[ContentType(MyContentTypes.Name)] // ★対象コンテンツタイプを必ず指定
internal sealed class MyLanguageClient : ILanguageClient
{
private readonly IMyLspHost _host;
[ImportingConstructor]
public MyLanguageClient(IMyLspHost host) => _host = host;
public string Name => "My LSP for MyLang";
public IEnumerable<string> FilesToWatch => Array.Empty<string>(); // 設定ファイル監視があれば指定
public object MiddleLayer => null;
public object CustomMessageTarget => null;
public async Task<Connection> ActivateAsync(CancellationToken token)
=> await _host.GetOrStartAsync(token).ConfigureAwait(false);
public Task OnLoadedAsync() => Task.CompletedTask;
public Task OnServerInitializedAsync() => Task.CompletedTask;
public Task OnServerInitializeFailedAsync(Exception e) => Task.CompletedTask;
}
}
コード:拡張パッケージ(起動時に明示ロード)
ILanguageClientBroker は VS サービスではなく MEF サービスです。IComponentModel から取得してください。SLanguageClientBroker を VS サービスとして探して null になるのはこの勘違いが原因です。
using Microsoft.VisualStudio.ComponentModelHost;
using Microsoft.VisualStudio.LanguageServer.Client;
using Microsoft.VisualStudio.Shell;
using System;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Task = System.Threading.Tasks.Task;
namespace MyVsix
{
[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
[Guid("00000000-0000-0000-0000-000000000001")]
// Visual Studio 起動直後に自動ロード(必要に応じて条件調整)
[ProvideAutoLoad(VSConstants.UICONTEXT.ShellInitialized_string, PackageAutoLoadFlags.BackgroundLoad)]
public sealed class MyPackage : AsyncPackage
{
protected override async Task InitializeAsync(CancellationToken ct, IProgress progress)
{
await base.InitializeAsync(ct, progress);
// MEF のエントリポイント
var componentModel = (IComponentModel)await GetServiceAsync(typeof(SComponentModel));
Assumes.Present(componentModel);
var broker = componentModel.GetService<ILanguageClientBroker>();
var myClient = componentModel.GetExtensions<ILanguageClient>()
.OfType<MyVsix.Lsp.MyLanguageClient>()
.FirstOrDefault();
if (broker is null || myClient is null)
return; // 依存が満たせなければ静かに諦める
// コンテンツタイプ "myLang" を対象に "明示ロード"
var metadata = new LanguageClientMetadata(new[] { MyVsix.Lsp.MyContentTypes.Name });
// LoadAsync は idempotent(共有ホストが同じ Connection を返すため二重起動にならない)
await broker.LoadAsync(metadata, myClient, ct);
}
}
}
.csproj:LSP サーバー exe を VSIX に同梱
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework> <!-- VS2022 マネージ拡張の一般的な TF -->
<IncludeBuildOutput>true</IncludeBuildOutput>
</PropertyGroup>
Always
true
なぜ二重起動しないのか(仕組みの分解)
拡張起動時の 明示ロード と、エディタ側の 自動ロード はどちらも最終的に ILanguageClient.ActivateAsync に到達します。本記事の構成では 共有ホスト(MEF Shared) が Connection を一意に保持するため、どちら経由でも 同じ接続 が返ります。さらにプロセス側で EnableRaisingEvents を有効化してクラッシュ時の解放を行うことで、再呼び出し時には再起動も可能です。
エラーの原因と対処(よくある落とし穴)
| 症状 | 原因 | 対処 |
|---|---|---|
SLanguageClientBroker が見つからない | Broker を VS サービスとして探している | IComponentModel → GetService<ILanguageClientBroker>() で取得(MEF サービス) |
UnifiedLspClient が null | コンテンツタイプ未定義 / 誤った [ContentType] 名 | ContentTypeDefinition と拡張子マップを実装し、[ContentType("myLang")] を一致させる |
componentModel が null | パッケージが読み込まれていない | [ProvideAutoLoad(..., BackgroundLoad)] または [ProvideUIContextRule] で自動ロード |
| LSP が二重起動する | インスタンスごとに別プロセスを起動 | MEF 共有ホストで Connection を集中管理し、排他制御(SemaphoreSlim 等)を入れる |
| LSP exe が見つからない | VSIX への同梱漏れ / 相対パス誤り | <IncludeInVSIX>true</IncludeInVSIX> と Assembly.Location ベースの解決に統一 |
スレッドモデルとパフォーマンスの注意
- Broker.LoadAsync は UI スレッド不要:本記事の実装はバックグラウンドで完結。UI スレッド切り替えは行いません。
- 起動時間:初回起動は拡張読み込み時に前倒しされるため、ファイルオープン時の待ち時間が減少。代わりに VS 起動直後の負荷がわずかに増加します。
- 例外の扱い:LSP サーバー起動失敗は握りつぶさず
Debug.WriteLineや ActivityLog に記録し、次回の再起動に備えてStop()で状態をクリア。
運用の設計ポイント
| やること | 理由 / 効果 | 実装ヒント |
|---|---|---|
| LSP 実行ファイルを VSIX に同梱 | 配布の一貫性、バージョンずれ防止 | .csproj の <IncludeInVSIX>、起動パスは Assembly.Location |
| 自動再接続 | クラッシュからの即時復旧 | Process.Exited でタスクと接続を破棄、次回呼び出しで再起動 |
| 設定ファイル監視 | オンザフライでサーバーに didChangeConfiguration 通知 | FilesToWatch にパターンを渡す |
| 多言語対応 | 1 拡張で複数言語をカバー | 共有ホストを言語別に分割 / ブローカーの GetClientsAsync で動的制御 |
ワンポイント:ILanguageClientBroker を直接使わない選択肢
どうしても LoadAsync の利用に不安がある場合、「ダミーの不可視テキストバッファ」 を生成して対象コンテンツタイプをロードし、自動トリガーを擬似的に発火させる方法もあります。ただし VS の将来バージョンで挙動が変わるリスクがあるため、公式のブローカー API による明示ロードを推奨します。
動作確認チェックリスト
| 確認項目 | OK 条件 |
|---|---|
| 拡張読み込み直後の LSP 起動 | VS 起動 → すぐに LSP プロセス 1 つが存在 |
| 対象ファイルオープン時 | 追加の LSP プロセスが増えない(接続は使い回し) |
| LSP サーバーのクラッシュ復旧 | 再び対象ファイルを触れると自動で再起動 |
| 設定ファイル変更 | didChangeConfiguration が届く(必要に応じて) |
ログとデバッグ
- サーバー側は
stderrを開発時のみ読み取り、VS の出力ウィンドウやデバッガへ転送。 - クライアント側の例外は
OnServerInitializeFailedAsyncで把握。共有ホストのStop()を呼んで 再起動可能な状態 に戻す。 - 必要なら VS の LSP 詳細ログを有効化し、送受信 JSON の粒度まで追う(本番では無効推奨)。
セキュリティと権限
- LSP 実行ファイルのパスはユーザー入力に依存させず、VSIX の既知ディレクトリからのみ解決。
UseShellExecute=falseとし、標準入出力のみに限定(--stdio)。- クラッシュループ対策として再起動バックオフ(指数的遅延)を導入可能。
拡張パターン:高度な制御が必要な場合
- カスタムメッセージ:
ILanguageClientCustomMessage/ILanguageClientCustomMessage2を実装して、拡張からサーバーへ独自通知。 - 初期化オプション:
GetInitializationOptionsAsyncによりサーバーに設定を引き渡し。バージョン識別や機能フラグの注入に有効。 - スキーマ連携:
FilesToWatchに設定ファイル(例:myLang.json)を渡してホットリロード。
完成までの最短手順(まとめ)
- コンテンツタイプと拡張子マップを定義(
myLang)。 - MEF 共有ホスト(
IMyLspHost)を実装し、LSP サーバー起動とConnectionを一元管理。 ILanguageClientを実装してActivateAsyncから共有ホストのConnectionを返す。AsyncPackage.InitializeAsyncでIComponentModel→ILanguageClientBroker→LoadAsyncにより明示ロード。- VSIX に LSP exe を同梱し、相対パス解決で確実に起動。
- 動作確認:拡張起動直後に LSP が一度だけ起動し、ファイルを開いても二重起動しないことを検証。
FAQ(実装後によく出る質問)
Q. LoadAsync を複数回呼ぶとどうなる?
A. 共有ホストが同じ Connection を返すため、LSP サーバーの実体は 1 つのままです。念のため SemaphoreSlim による排他と _startTask の二重チェックで可搬性を高めています。
Q. ファイル未オープンで何を提供できるの?
A. シンボルインデックスの先行構築、ワークスペーススキャン、設定のプリフェッチ等です。ユーザーが最初のファイルを開いた瞬間に応答性が向上します。
Q. 自動起動のタイミングを遅らせたい
A. [ProvideAutoLoad] の UI コンテキスト(例:SolutionExists)に変更、または [ProvideUIContextRule] で条件式を細かく定義します。
最終結論
「ファイルを開かずに LSP サーバーを起動したい」「二重起動を防ぎたい」という要件は、MEF 共有ホストで接続を一意化し、ILanguageClientBroker を MEF 経由で正しく取得して 起動タイミングを明示化すれば、シンプルかつ堅牢に実現できます。この記事の最小実装を土台に、監視・再接続・設定ホットリロードなどの運用要素を加えれば、プロダクション品質の VSIX-LSP 連携に十分耐える構成になります。

コメント