Skip to content

Remove lsp4j #13

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

Merged
merged 80 commits into from
Aug 9, 2022
Merged

Remove lsp4j #13

merged 80 commits into from
Aug 9, 2022

Conversation

mainej
Copy link
Contributor

@mainej mainej commented Jul 15, 2022

Replaces lsp4j with a Clojure implementation. The full list of dependencies is now clojure, core.async, cheshire, and camel-snake-kebab.

Also merges the lsp4clj-server and lsp4clj-protocol projects into one top-level project, lsp4clj.

clojure-lsp has an accompanying PR.

@Cyrik, as stated in the CHANGELOG, other users of lsp4clj (you, I think) are encouraged to upgrade. You can follow that clojure-lsp PR as a guide on the changes that'll need to be made. They're fairly extensive, but I believe will make lsp4clj a more stable platform for LSP development. When you're upgrading if you'd like a little direction, I'd be happy to chat. Perhaps we can turn our discussion into edits to the Usage section in the README.

Notable changes:

  • As described in Replace lsp4j with internal communication logic #8, servers can choose which requests and notifications they respond to by implementing the lsp4clj.server/receive-request and lsp4clj.server/receive-notification multimethods. (In contrast to the protocols based approach we had before.) This gives servers much more flexibility. They can implement new parts of the spec at their leisure, or according to the needs of their language. They can also choose when and how to implement asynchronicity. The tradeoff is that much of the spec checking and logging moves back to clojure-lsp and other servers. The servers also have to know and use the names of the LSP requests/notifications. Personally, I find clojure-lsp.server code to be more readable now, even though it has a few more responsibilities.
  • Input maps are all coerced to kebab-case. The server receives whatever the client sent, which should follow the LSP spec.
  • Output specs still exist, but do only very lightly conformation. Of what conformation remains, most of it converts keywords to enum values. There are a few places where more complex conformation happens. Honestly, I'd like to remove almost all conformation. I think it will be easier if you can look up what to output based on the LSP spec, not the LSP spec, plus a bunch of reasoning about coercion.
  • It is much less code to initialize a server. None of it is Java interop.
  • We have a path to working on Support for more communication channels #1. We need code that converts a socket into a pair of input and output channels. Otherwise, much of the code can stay the same.

Fixes #8.

mainej added 30 commits July 14, 2022 12:21
This implements a stdio server, which reads JSON-RPC messages off its
:in, processes them, and sends responses/notifications to :out. There's
still a lot of exception handling to do, and it needs a careful
comparison to the spec.
Clients have two ways to shutdown. They can send the 'shutdown' request,
or they can close their end of the server input. The shutdown is mostly
for the language server to do cleanup, so now we do nothing. On exit, we
simulate the server input closing, which blocks waits for messages to
finish being processed, then closes the output, then unblocks.
I've been having a problem with the new code that wasn't happening in
lsp4j.

Here's what I _think_ is going on in lsp4j. (Java code is so hard for me
to read.)

First we createLauncher. This instantiates several objects:

1. A RemoteEndpoint. This is the thing that will processes messages. In
   ring terms it is the handler.
2. A MessageConsumer. This is a list of things that will all 'consume'
   messages. Each one does something with a message, then passes the
   message to the next consumer. Technically, the RemoteEndpoint is also
   a MessageConsumer. It's the first in the list. The last one writes
   JSON-RPC to the wire. Optionally, in between, you can have tracers or
   other consumers. In ring terms, this is a stack of middleware that
   handles a request and transforms the response in several ways.
3. A StreamMessageProducer. This is the thing that will read JSON-RPC
   off the wire, convert it to Java obects, and call the MessageConsumer
   list. In ring terms, it's middleware that transforms the request
   before calling the handler.
4. A ConcurrentMessageProcessor. This is the thing that will connect the
   producer to the consumer list.

Then we ask the launcher to startListening. This starts a thread in
which the ConcurrentMessageProcessor will:

1. Ask the producer to start a loop that reads messages.
2. Pipe those messages to the consumer stack.

Despite using newCachedThreadPool, (and despite the name "Concurrent")
only [one thread is
created](https://github.com/eclipse/lsp4j/blob/6e20087ec9e6201a6cdabf746c7233fe3c87abbc/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/json/ConcurrentMessageProcessor.java#L105-L108)
to read messages.

So, one background thread reads messages from the client, and forwards
them to the main thread. In the main thread, lsp4j receives requests and
notifications from the client, processes them (by forwarding them to
clojure-lsp) and replies.

On the clojure-lsp side, we start many threads and so it's possible for
many of them to ask lsp4j to send messages to the client—for example, by
calling publishDiagnostics—all at the same time. publishDiagnostics
doesn't block waiting for the client to respond. But calling that method
will result in an outbound message.

All outbound messages, whether responses to client requests or
server-generated requests and notifications, all go to a single object.
(There's some messiness with Service objects and reflection in here that
I don't really understand.) In any case, as it's writing JSON output,
that object
[synchronizes](https://github.com/eclipse/lsp4j/blob/6e20087ec9e6201a6cdabf746c7233fe3c87abbc/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/json/StreamMessageConsumer.java#L66)
its writes across threads.

I'm not sure how big the buffer is between the server's output and the
client's input, but if the client is slow to read messages, the buffer
will fill up and the server won't be able to write.

So, effectively you can only write messages as fast as lsp4j can put
them on the output stream. If the server-side writer can't keep up or
the client-side reader is slow, publishDiagnostics will block waiting
for other threads to finish writing.

In the new architecture, output is modeled a bit differently. There's a
channel which gets messages put on it. A go loop is reading messages off
the channel, one-by-one, serializing them to JSON and writing them to
the output stream.

Now we're in a situation where that go block, like the synchronized
lsp4j writer before it, can only write messages as fast as the client is
willing to read them.

If clojure-lsp creates thousands of messages to `put!` on the chan, and
the go block doesn't drain the put queue fast enough, you end up with
the exception about having too many pending puts.

Unfortunately, I've already seen this exception in the wild. When
metabase starts up, we publish thousands of diagnostics messages, one
for each of the files in that very large project. The server-side writer
is slow (possibly because the client-side reader is slow) and so we get
the pending puts exception. I think we are _just_ over the limit. The
put queue can only contain 1024 messages, but metabase has 1084 Clojure
files.

After some experimentation I have one way to fix it, which is this
patch. In lsp4clj, in `send-notification`, I can use `>!!` instead of
`put!` so that if the go block gets backed up, we block further
notifications. (I tried `(go (>!  output msg))` but that causes the same
pending puts exception, which the core.async wiki [suggests will be the
case](https://github.com/clojure/core.async/wiki/Go-Block-Best-Practices#general-advice).)

After changing `send-notification` to use `>!!`, `spawn-async-tasks` can
no longer use `go-loop` (because you aren't supposed to use blocking
operations inside go blocks for fear of depleting all the threads that
process the go blocks). So, on the clojure-lsp side, `spawn-async-tasks`
has to be switched to `(thread (loop []))`. Maybe that's fine. It
dedicates 4 threads to the 4 async tasks, but that's not terrible.

This works, and I'd be happy with it as a long-term solution. It may
even be correct... we are, after all, doing blocking io. Perhaps in a
sense, we've essentially discovered a subtle bug. All along we've been
doing blocking io inside a go block. We probably haven't ever depleted
all the go threads, but it's been possible... and now, it's fixed!

But maybe there's a way where it can all be solved on the lsp4clj side,
without changing `spawn-async-tasks`. I'll think about that more.
Comment on lines +32 to +34
Server implementors should create `defmethod`s for the messages they want to process. (Other methods will be logged and responded to with a generic "Method not found" response.)

These `defmethod`s receive 3 arguments, the method name, a "context", and the `params` of the [JSON-RPC request or notification object](https://www.jsonrpc.org/specification#request_object). The keys of the params will have been converted (recursively) to kebab-case keywords. Read on for an explanation of what a "context" is and how to set it.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing I think we lost using multimethods is that we can't require that clients implement specific methods like we had on protocol, the LSP spec has some methods that all server should provide, they are required methods, like initialize, diagnostics, definition etc. I'm ok for now with this, we can think on how to make required some methods in the future

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be pedantic, you're not required to implement every method on a protocol, the docs just say that you should. If you don't, you don't get any compile time errors, though you do get a runtime error if you try to call the method you didn't define.

But I understand what you're saying—a protocol is a stronger indication of which methods you're expected to implement.

This is just one of the drawbacks to the multimethods. They also don't give you anywhere to hang extra data about handlers. I can imagine wanting to flag certain messages as cancellable, give them validators to apply to their input or output, or more generally give them middleware style wrappers, etc.

I think the best way to think about this is that one of the typical responsibilities of a server is to route requests. The multimethods work for that, but reitit, Compojure, et al. address the fact that there's more you might want to do with routing.

On the other hand, I haven't run into anything on the clojure-lsp side yet where I really regret the simplicity of the multimethods.

So, I guess my feeling is that multimethods might not be the best design long-term, but I'm not sure I have enough information to commit to another design either.

I've vaguely considered expressing the "router" as a hashmap, keyed by method name, which you pass in to the server when you create it. The server could validate that certain keys were present. That would also give us lots of flexibility about what the values of the hashmap are. They could be functions to call as the handler (much like a multimethod, which maps dispatch values to functions). Or they could be other hashmaps, with a handler function in each. We could add other data over time. If you feel strongly that this is a better solution, I can work on it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be pedantic, you're not required to implement every method on a protocol, the docs just say that you should. If you don't, you don't get any compile time errors, though you do get a runtime error if you try to call the method you didn't define.

yeah, the issue is that most clients expect that some methods are always available like diagnostics, but I agree it's best to be a little bit more loose than strict for now.

Or they could be other hashmaps, with a handler function in each. We could add other data over time. If you feel strongly that this is a better solution, I can work on it.

I think it would work well, but I'm ok trying the multimethods for now

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also split on this. On the one hand, multimethods bring great simplicity, but our tooling for them is a lot worse. Previously I could find the implementation of the specific message handler with a "find reference", that's gone with multimethods. Additionally, there's no indication of all possible methods I could implement.
I still like the multimethod approach, but maybe this will drive us to add better multimethod stuff into clojure-lsp 😄

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Cyrik we improved a lot defmethods support on clojure-lsp, doesn't find implementations work for that case?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing I'm missing from defmethods is a way of seeing references to a specific method. I know that this isn't possible in the general case, which is the whole point of defmethods, but often enough there are hardcoded calls to a specific method that could be shown as a direct reference. Something like (receive-request "shutdown").
The other thing is completeness, at least in this case.

But in I also fully agree with @mainej that we should leave this as is for now since these are very minor gripes with the system.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Cyrik let me see if I follow...

Suppose we have this multimethod:

(defmulti caps identity)

(defmethod caps "a" [_] "A")
(defmethod caps "b" [_] "B")

At call sites like the following, you know for certain that (defmethod caps "a" ...) is being called, not (defmethod caps "b" ...).

(caps "a")

In theory clojure-lsp could provide navigation tools to link the a defmethod to its usage.

Of course this isn't possible when it's ambiguous which defmethod is being called:

(let [lower (rand-nth ["a" "b"])]
  (caps lower))

In these situations clojure-lsp couldn't help.

Assuming I'm following, I agree, that makes it harder to understand how multimethods, or really any kind of polymorphism, is being used. Polymorphism limits tooling, and that's true of all editors and languages. Maybe the next generation of tooling will offer more insight into polymorphic calls!

For these particular lsp4clj multimethods—receive-request and receive-notification—I'd say the problem isn't very big. There's only one place that calls receive-request and one that calls receive-notification (both in lsp4clj), so there's not much need to find the call sites.

I think there's a stronger argument that the other main entrypoints into lsp4cljsend-request and send-notification—exhibit a similar problem, even though they aren't multimethods. They accept the JSON-RPC method as an argument, so it could be difficult to find all the places that, for example, call (lsp.server/send-notification server "$/progress" message). In clojure-lsp we've worked around this. We have only one place that calls $/progress, a function called publish-progress. If we want to find all the call sites, we can use find-references on publish-progress. And in fact, all the calls to send-request and send-notification are in similar wrapper functions, so it's pretty easy to find references.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mainej Yeah that's the exact case I'm talking about. In my experience, most multimethods are built in this way (using the first param for dispatch). But all of this is very much unrelated so we should probably stop the topic 😄

To your second point: you're correct that this happens more often, I just didn't notice the change yet. Previously it was easy to find references on the ILSPProducer methods and you'll now have to do the work yourself. Maybe this could be pulled into lsp4clj as optional entry points. But I know that @ericdallo and I have differing opinions on how much lsp4clj should provide, so I'm also happy to just do that on our end. At some point, I want to publish a library with all the extra utility stuff around lsp4clj anyway (file-management, URL handling, and so on).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could open a new discussion on lsp4clj to see if we could provide something that help you like a different project using lsp4clj under the hood, I think discussing the problem a little bit more would make us understand better the problem

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#14

Copy link
Member

@ericdallo ericdallo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great code-wise, awesome work!

@mainej mainej mentioned this pull request Aug 3, 2022
mainej added 4 commits August 6, 2022 20:04
So servers can indicate that they intend to send logs and traces to the
same place and want order to be preserved.
@mainej mainej merged commit b2c8c63 into master Aug 9, 2022
@mainej mainej deleted the v1 branch August 9, 2022 21:22
@mainej mainej mentioned this pull request Aug 13, 2022
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

Successfully merging this pull request may close these issues.

Replace lsp4j with internal communication logic
4 participants