Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
anthony-c-martin opened this issue Dec 4, 2020 · 36 comments
Open

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

anthony-c-martin opened this issue Dec 4, 2020 · 36 comments

Comments

@anthony-c-martin
Copy link

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

@david-driscoll
Copy link
Member

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.

@rynowak
Copy link

rynowak commented Dec 4, 2020

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 :)

@anthony-c-martin
Copy link
Author

anthony-c-martin commented Dec 4, 2020

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.

@david-driscoll
Copy link
Member

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" }),

@david-driscoll
Copy link
Member

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);

@david-driscoll
Copy link
Member

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.

@david-driscoll
Copy link
Member

@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.

@NTaylorMullen
Copy link
Contributor

@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

@anthony-c-martin
Copy link
Author

anthony-c-martin commented Dec 4, 2020

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.

@anthony-c-martin
Copy link
Author

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?

@NTaylorMullen
Copy link
Contributor

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

@anthony-c-martin
Copy link
Author

@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.

@david-driscoll
Copy link
Member

interesting!

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

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

@david-driscoll
Copy link
Member

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.

@david-driscoll
Copy link
Member

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

@anthony-c-martin
Copy link
Author

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

@david-driscoll
Copy link
Member

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.

@david-driscoll
Copy link
Member

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
Copy link
Author

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

I'm on Teams - antmarti@microsoft.com

@TylerLeonhardt
Copy link
Collaborator

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

@david-driscoll
Copy link
Member

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.

@anthony-c-martin
Copy link
Author

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

@TylerLeonhardt
Copy link
Collaborator

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.

@TylerLeonhardt
Copy link
Collaborator

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.

@anthony-c-martin
Copy link
Author

anthony-c-martin commented Dec 7, 2020

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.

@anthony-c-martin
Copy link
Author

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.

@david-driscoll
Copy link
Member

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.

@TylerLeonhardt
Copy link
Collaborator

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

@david-driscoll
Copy link
Member

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

@david-driscoll david-driscoll pinned this issue Dec 15, 2020
@anthony-c-martin
Copy link
Author

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

@david-driscoll
Copy link
Member

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.

@s-KaiNet
Copy link

s-KaiNet commented Dec 28, 2020

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?

@elringus
Copy link

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?

@anthony-c-martin
Copy link
Author

anthony-c-martin commented Nov 19, 2021

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.

@Skleni
Copy link

Skleni commented Jan 4, 2022

@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.

@elringus
Copy link

elringus commented Jan 4, 2022

@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:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants