Visual Studio拡張でLSPサーバーをファイル未オープンで起動し二重起動を防ぐ方法(ILanguageClient/ILanguageClientBroker実践)

Visual Studio 拡張(VSIX)で Language Server Protocol(LSP)サーバーを「対象言語のファイルを開かずに」起動したい、かつファイルを開いたときに二重起動させたくない——この記事はその一点突破のための実装ガイドです。SLanguageClientBroker が見つからない、UnifiedLspClientnull になる等の定番エラーの原因と対処も、実装と同じ粒度で整理します。

目次

問題の本質と最終形のイメージ

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コンポーネント)

  1. コンテンツタイプ定義[ContentType] と拡張子マップで VS に「対象言語」を認識させる。
  2. 共有ホスト(Shared:LSP サーバーのプロセス起動と Connection を集中管理。
  3. ILanguageClient 実装ActivateAsync で「共有ホスト」の Connection を返す。

そして AsyncPackageInitializeAsyncILanguageClientBroker から明示ロード(手動起動)します。ファイルオープン時の自動ロードが来ても、共有ホストが同じ接続を返すため、実体は一度しか起動しません。

コード:コンテンツタイプと拡張子の定義

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 サービスとして探しているIComponentModelGetService<ILanguageClientBroker>() で取得(MEF サービス)
UnifiedLspClientnullコンテンツタイプ未定義 / 誤った [ContentType]ContentTypeDefinition と拡張子マップを実装し、[ContentType("myLang")] を一致させる
componentModelnullパッケージが読み込まれていない[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)を渡してホットリロード。

完成までの最短手順(まとめ)

  1. コンテンツタイプと拡張子マップを定義(myLang)。
  2. MEF 共有ホスト(IMyLspHost)を実装し、LSP サーバー起動と Connection を一元管理。
  3. ILanguageClient を実装して ActivateAsync から共有ホストの Connection を返す。
  4. AsyncPackage.InitializeAsyncIComponentModelILanguageClientBrokerLoadAsync により明示ロード。
  5. VSIX に LSP exe を同梱し、相対パス解決で確実に起動。
  6. 動作確認:拡張起動直後に LSP が一度だけ起動し、ファイルを開いても二重起動しないことを検証。

FAQ(実装後によく出る質問)

Q. LoadAsync を複数回呼ぶとどうなる?
A. 共有ホストが同じ Connection を返すため、LSP サーバーの実体は 1 つのままです。念のため SemaphoreSlim による排他と _startTask の二重チェックで可搬性を高めています。

Q. ファイル未オープンで何を提供できるの?
A. シンボルインデックスの先行構築、ワークスペーススキャン、設定のプリフェッチ等です。ユーザーが最初のファイルを開いた瞬間に応答性が向上します。

Q. 自動起動のタイミングを遅らせたい
A. [ProvideAutoLoad] の UI コンテキスト(例:SolutionExists)に変更、または [ProvideUIContextRule] で条件式を細かく定義します。

最終結論

「ファイルを開かずに LSP サーバーを起動したい」「二重起動を防ぎたい」という要件は、MEF 共有ホストで接続を一意化し、ILanguageClientBroker を MEF 経由で正しく取得して 起動タイミングを明示化すれば、シンプルかつ堅牢に実現できます。この記事の最小実装を土台に、監視・再接続・設定ホットリロードなどの運用要素を加えれば、プロダクション品質の VSIX-LSP 連携に十分耐える構成になります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次