OmniSharp/csharp-language-server-protocol

[info request] Hosting the language server code in Blazor?

anthony-c-martin opened this issue ยท 36 comments

We're using this library to provide LSP support for Bicep. We have an browser demo at https://aka.ms/bicepdemo which calls into the compiler code directly using Blazor/WASM, using Microsoft.JSInterop to compile, emit diagnostics, get semantic tokens, and pass the results back to the Monaco editor, but we're not using the language server for this - instead we've created a few custom functions for it.

The monaco-languageclient library can be used to hook monaco up to a language server which would provide much of the functionality that VSCode offers in a browser. It would be extremely cool to be able to simply use the LSP in a browser without the need for a back-end server.

I'm curious as to whether anyone has tried to run this server code via Blazor before. I've been experimenting with it and am able to get the initialize request/response negotiation to take place, but I don't see the client/registerCapability request come through from the server. I suspect there may be some sort of message pump that needs to run, but am not at all familiar with the Reactive library that's being used. Any pointers that you can give me would be awesome!

Here's an example of the code changes I've been experimenting with to hook this up to monaco: main...antmarti/experiment/monaco_lsp

I was thinking about this a week or two ago, in theory things should "just work" because the language server is just a .NET Standard library. This sounds fun, so I'll take a look at your code and see if I can get it running.

I'm curious as to whether anyone has tried to run this server code via Blazor before. I've been experimenting with it and am able to get the initialize request/response negotiation to take place, but I don't see the client/registerCapability request come through from the server. I suspect there may be some sort of message pump that needs to run, but am not at all familiar with the Reactive library that's being used. Any pointers that you can give me would be awesome!

If there is a message pump at play one of the challenges might be that pump using a dedicated thread. I don't think Blazor WASM supports threads yet. Certain BCL methods that interact with threading will "spin" and fry your CPU :)

I was thinking about this a week or two ago, in theory things should "just work" because the language server is just a .NET Standard library. This sounds fun, so I'll take a look at your code and see if I can get it running.

Thanks! If you need any pointers in getting it running, let me know. It should just work if you clone the repo and run:

cd src/playground
npm i
npm start

I've stuck some haphazard logging in which should get written out to the browser console. npm start doesn't watch the C# code for changes, so has to be killed and restarted to recompile.

For input the process scheduler runs on the thread pool, which I think should be fine.
https://github.com/OmniSharp/csharp-language-server-protocol/blob/master/src/JsonRpc/InputHandler.cs#L86

For output however... I think it by default runs a dedicated thread.

new EventLoopScheduler(_ => new Thread(_) { IsBackground = true, Name = "OutputHandler" }),

Okay here's a possible quick fix. When setting up the server... try this. IScheduler should be System.Reactive.Scheduling.IScheduler (or something like that)

options.Services.AddSingleton<IScheduler>(TaskPoolScheduler.Default);

I theory that should kick the output handler to use the IScheduler provided in the container based on how I think DryIoc will pick constructors.

@ryanbrandenburg @NTaylorMullen @TylerLeonhardt thoughts, should I just move to use the task pool scheduler for handing input/output? At the time a dedicated thread "made sense" but honestly it probably doesn't matter.

Input is already on the task pool and working fine.
Output isn't however it does ensure ordering, so we shouldn't there shouldn't really be any big problems.

@ryanbrandenburg @NTaylorMullen @TylerLeonhardt thoughts, should I just move to use the task pool scheduler for handing input/output? At the time a dedicated thread "made sense" but honestly it probably doesn't matter.

Having a dedicated thread has been risky because if something doesn't ConfigureAwait(false) and blocks you're doomed. We've actually encountered that issue once or twice in VS (as I'm sure you recall) so relying on the task pool scheduler doesn't sound awful. Are there any other drawbacks?

All that said for extra background info, we run Razor's language server in-proc in VS today which I presume from the quick glance at this thread similar types of things are trying to be acheived.

Here's where we create our own abstraction to start the spinup of the O# framework bits in VS: https://github.com/dotnet/aspnetcore-tooling/blob/feb060660bf14c9da3f284a72fe5f86390d3ab65/src/Razor/src/Microsoft.VisualStudio.LanguageServerClient.Razor/RazorLanguageServerClient.cs#L126-L129

And here's our actual abstraction that can rely on in-proc or out of proc streams: https://github.com/dotnet/aspnetcore-tooling/blob/feb060660bf14c9da3f284a72fe5f86390d3ab65/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs#L72-L75

Okay here's a possible quick fix. When setting up the server... try this. IScheduler should be System.Reactive.Scheduling.IScheduler (or something like that)

options.Services.AddSingleton<IScheduler>(TaskPoolScheduler.Default);

I gave this a go, but didn't see any observable difference in behavior.

I did notice that the Reactive.Wasm library I'm trying to use to replace the default scheduler doesn't appear to be doing what it's meant to in .NET 5 - in particular these checks no longer seem to work:
https://github.com/reactiveui/Reactive.Wasm/blob/a226dc0bb4f010c248568eb67b0b8e5b768358f5/src/System.Reactive.Wasm/Internal/WasmScheduler.cs#L136-L137
https://github.com/reactiveui/Reactive.Wasm/blob/a226dc0bb4f010c248568eb67b0b8e5b768358f5/src/System.Reactive.Wasm/Internal/WasmPlatformEnlightenmentProvider.cs#L27-L28

In theory if they were working, I should be able to do:

options.Services.AddSingleton<IScheduler>(WasmScheduler.Default);

I'll see if I can fix up the above checks locally and get that working.

All that said for extra background info, we run Razor's language server in-proc in VS today which I presume from the quick glance at this thread similar types of things are trying to be acheived.

Thanks for the pointers! We have the language server running as a standalone exe, which we use for VSCode integration, but as an experiment, I'm trying to see if we can also host the language server fully in a web browser, using Blazor/WASM without a backend - I think that's where the complexity is mostly coming from. Is that something your team has attempted by any chance?

I think that's where the complexity is mostly coming from. Is that something your team has attempted by any chance?

Ah, ya I can definitely imagine that being difficult ๐Ÿ˜„. No we haven't tried that but I can just imagine how the threading models may make things more difficult in addition to things like file watchers

@david-driscoll I just got an end-to-end working with a very hacky change here:

Instead of adding the message to the queue, I just sent it directly with:

ProcessOutputStream(value, CancellationToken.None).Wait();

So I think that definitely confirms that it's something to do with the scheduler. Interestingly, I noticed that in the version of the language server we're using (0.18.3) it is using TaskPoolScheduler.Default rather than EventLoopScheduler.

I'm going to try and get a more solid PoC together by replacing IOutputHandler in the IoC container.

interesting!

options.Services.AddSingleton<IScheduler>(ImmediateScheduler.Instance); also appears to work.

@anthony-c-martin are you on slack or msteams?

So I'm running into an error Request client/registerCapability failed with message: i.languages.registerDocumentSemanticTokensProvider is not a function. Seems the monaco editor doesn't support semantic tokenization yet. However, disabling that things seem to work.

Here's my branch for you reference from:
Azure/bicep@Azure:antmarti/experiment/monaco_lsp...david-driscoll:davidd/experiment/monaco_lsp

Couple notes: I was building locally with the latest version of the library (0.19.0-beta.1) so the C# changes are the changes required based on the breaking changes I've documented.

Also I was able to simplify the interop a little bit by using StreamMessageReader/StreamMessageWriter and a Duplex stream. This writes all the expected header information, so you don't have to serialize on the Blazor side, instead you just write the bytes directly into the pipe. Sending from the server to client also happens similarly.

I've created this PR #458 so we can configure the schedulers specifically.

So I'm running into an error Request client/registerCapability failed with message: i.languages.registerDocumentSemanticTokensProvider is not a function. Seems the monaco editor doesn't support semantic tokenization yet. However, disabling that things seem to work.

Here's my branch for you reference from:
Azure/bicep@Azure:antmarti/experiment/monaco_lsp...david-driscoll:davidd/experiment/monaco_lsp

Couple notes: I was building locally with the latest version of the library (0.19.0-beta.1) so the C# changes are the changes required based on the breaking changes I've documented.

Also I was able to simplify the interop a little bit by using StreamMessageReader/StreamMessageWriter and a Duplex stream. This writes all the expected header information, so you don't have to serialize on the Blazor side, instead you just write the bytes directly into the pipe. Sending from the server to client also happens similarly.

This is AMAZING, thank you so much for your help! I ran into the same issue with the language client - looks like semantic support has only been added to a preview version, and that they haven't yet picked up the latest LSP spec. For now, since we've already implemented our own semantic token handler anyway, I've reverted back to using this for now until the language client has actual support for it. I've picked up a bunch of your changes and updated my branch: main...antmarti/experiment/monaco_lsp.

I've pushed a demo of this here: https://bicepdemo.z22.web.core.windows.net/experiment/lsp/index.html

This has me thinking of making a Blazor Component that uses the monaco editor... but to try to make as much as possible of it actually live in C# and use the LanguageClient for interacting with it....

Other than the annoying part of converting the monaco api into C#... ugh.

Right now I don't think I have the bandwidth to tie monaco and blazor together. I might spike something out next weekend. I looked at https://github.com/microsoft/monaco-editor/blob/master/monaco.d.ts and while I'm sure I could... that's a lot of code to keep in sync, so I would want to build out some sort of tool to integrate the two together.

I found this project, and posted an issue there canhorn/EventHorizon.Blazor.TypeScript.Interop.Generator#31 to see what might be needed to support generation interop with monaco.d.ts as I'm just not prepared for the maintenance that would entail.

In the meantime there is recent activity on https://github.com/TypeFox/monaco-languageclient updating it to the latest version (that would include semantic tokens), you might be able to pin to the latest master branch and see if that works (I have not tried).

@anthony-c-martin are you on slack or msteams?

I'm on Teams - antmarti@microsoft.com

Shoot now I want to run the PowerShell language server in Blazor!

I think you can, you'll just have to do something similar to the bicep solution using monaco + monaco-languageclient, it totally works, there might be some issues if you use the file system APIs but those can always be fixed.

@TylerLeonhardt feel free to reach out if you'd like any pointers for the Bicep code!

I'm skeptical the PowerShell API will "just work" in Blazor WASM but worth a shot. @anthony-c-martin how did you "start the language server" in Blazor WASM? I'd love to take a peak at how the language server is hooked up to Monaco Editor.

For context, I've used the Monaco-languageclient before, but only their stdio option where the language server was running in a separate process on the machine.

I'm skeptical the PowerShell API will "just work" in Blazor WASM but worth a shot. @anthony-c-martin how did you "start the language server" in Blazor WASM? I'd love to take a peak at how the language server is hooked up to Monaco Editor.

[credit goes to @david-driscoll for a lot of this code]

Here's where the server is being initialized:
https://github.com/Azure/bicep/blob/16d7eb7fd5a92dadf6704f1c49e9246cf52b4da9/src/Bicep.Wasm/Interop.cs#L43-L58
The Server class is our own, but is really a thin wrapper around the Omnisharp Server class. The important pieces here are initializing the input/output pipes, and overriding the scheduler with ImmediateScheduler.Instance.

Here's the C# method that the JS code invokes to send data from client to server:
https://github.com/Azure/bicep/blob/16d7eb7fd5a92dadf6704f1c49e9246cf52b4da9/src/Bicep.Wasm/Interop.cs#L62

Here's where the C# code invokes the JS code to send data from server to client:
https://github.com/Azure/bicep/blob/16d7eb7fd5a92dadf6704f1c49e9246cf52b4da9/src/Bicep.Wasm/Interop.cs#L78

Here's the JS code to setup the send/receive with the server via the Blazor methods/callbacks:
https://github.com/Azure/bicep/blob/16d7eb7fd5a92dadf6704f1c49e9246cf52b4da9/src/playground/src/helpers/lspInterop.ts#L24-L34

On startup I'm initializing the Blazor code from JS and setting the interop variable which can be used to invoke Blazor code with the following:
https://github.com/Azure/bicep/blob/16d7eb7fd5a92dadf6704f1c49e9246cf52b4da9/src/playground/src/helpers/lspInterop.ts#L5-L14

If you follow through the TS code, you should be able to see how the above is hooked into monaco-languageclient. I'm probably going to try and refine this code at some point to see if I can clean up the use of globals, and also to see if I can use a webworker to run the Blazor code.

Right now I don't think I have the bandwidth to tie monaco and blazor together. I might spike something out next weekend. I looked at https://github.com/microsoft/monaco-editor/blob/master/monaco.d.ts and while I'm sure I could... that's a lot of code to keep in sync, so I would want to build out some sort of tool to integrate the two together.

Out of interest, what are the benefits of implementing the translation layer between LSP & monaco's "custom LSP" in C# vs relying on monaco-languageclient to do it? I quite like the clean separation of having the TS code handle the translation and communicating with the C# code via LSP.

I just think it would be pretty cool to have a fully featured wrapper for monaco from the C# side. The added extra would make it it easier to consume using the client.

Probably because then @david-driscoll could guarantee that the monaco language client was up-to-date on the LSP spec.

Going to pin this issue for any passers by as it is truly a cool feature.

FWIW I think we need one of dotnet/aspnetcore#17730 or dotnet/aspnetcore#5475 to really unlock the power of this, because at the moment synchronous dotnet code locks up the UI thread, which feels a little janky when typing.

There's also this project which I haven't really investigated that might work as a stopgap: https://github.com/Tewr/BlazorWorker

I think a web worker would be perfect. Your UI (TypeScript) starts the worker, and you interop with the worker using postmessage. The worker then just has to interop with the language server.

Thank you guys, you helped me a lot to understand some ideas. I'm trying to build a small POC on blazor and monaco based C# code editor with code completion. However, I cannot get code completion to work.

What I've done:

  • in JS created monaco-editor and configured monaco-languageclient (in the same way as Bicep's playground)
  • in wasm created an interop class (in a similar way as in Bicep solution):
public class Interop
{
	private LanguageServer languageServer;
	private readonly IJSRuntime jsRuntime;
	private readonly PipeWriter inputWriter;
	private readonly PipeReader outputReader;

	public Interop(IJSRuntime jsRuntime)
	{
		this.jsRuntime = jsRuntime;
		var inputPipe = new Pipe();
		var outputPipe = new Pipe();

		inputWriter = inputPipe.Writer;
		outputReader = outputPipe.Reader;

		languageServer = LanguageServer.PreInit(opts =>
		{
			
			opts.WithInput(inputPipe.Reader);
			opts.WithOutput(outputPipe.Writer);
			opts.Services.AddSingleton<IScheduler>(ImmediateScheduler.Instance);
		});

		Task.Run(() => RunAsync(CancellationToken.None));
		Task.Run(() => ProcessInputStreamAsync());
	}

	public async Task RunAsync(CancellationToken cancellationToken)
	{
		await languageServer.Initialize(cancellationToken);

		await languageServer.WaitForExit;
	}

	[JSInvokable]
	public async Task SendLspDataAsync(string jsonContent)
	{
		var cancelToken = CancellationToken.None;
		Console.WriteLine("jsonContent");
		Console.WriteLine(jsonContent);

		await inputWriter.WriteAsync(Encoding.UTF8.GetBytes(jsonContent)).ConfigureAwait(false);
	}

	private async Task ProcessInputStreamAsync()
	{
		do
		{
			var result = await outputReader.ReadAsync(CancellationToken.None).ConfigureAwait(false);
			var buffer = result.Buffer;
			Console.WriteLine("ProcessInputStreamAsync");
			await jsRuntime.InvokeVoidAsync("ReceiveLspData", Encoding.UTF8.GetString(buffer.Slice(buffer.Start, buffer.End)));
			outputReader.AdvanceTo(buffer.End, buffer.End);

			// Stop reading if there's no more data coming.
			if (result.IsCompleted && buffer.IsEmpty)
			{
				break;
			}
			// TODO: Add cancellation token
		} while (!CancellationToken.None.IsCancellationRequested);
	}
}

Code completion doesn't work, because I haven't registered CodeCompletionHandler. I don't understand which one to use, because in Bicep you use a custom completion handler, in my POC I would like to use O# completion handler.
Do you have any hints on how to implement it?

I've made a solution, that compiles C# project into single-file UMD library: https://github.com/Elringus/DotNetJS

Tried to use the server with it, but not sure how to deal with input/output. Console.STD won't work, obviously. Can we somehow run the server via websocket?

Tried to use the server with it, but not sure how to deal with input/output. Console.STD won't work, obviously. Can we somehow run the server via websocket?

@Elringus Nice, I'll check that library out!

Here's how I've been doing things in my experimental branch - using a simple send/receive method to pass JSONRPC back and forth from JS <-> C#:

Not the most elegant/performant, but it works well enough for now. Being able to have client-side Blazor host a websocket would make this a lot nicer. Failing that, being able to hook up the JS streams / C# pipes to each other directly would avoid the serialization/deserialization step.

@Elringus I'm currently trying to use your library to get our OmniSharp-based Language Server to run in an VS Code Web extension. This sounds very similar to what you want to achieve. May I ask if you already managed to get that working? My current status is that I can run the language server in a Blazor project (thanks to the information in this thread), but in the web extension the server never finishes initialization.

@Skleni I've switched to Microsoft's reference LSP implementation in JS (https://github.com/microsoft/vscode-languageserver-node), while reusing the existing language-specific C# code via DotNetJS:

โ€” this way we can get up-to-date LSP implementation and native webworker transport layer out of the box, while keeping all the handlers logic in C#.

Regarding VS Code, there were 2 issues with this workflow, but they're both solved in insiders stream now and should become available in the main stream in February: