This is an archive of the discontinued LLVM Phabricator instance.

[clangd] Add a callback mechanism for handling responses from client.
ClosedPublic

Authored by hokein on Jul 29 2019, 2:45 AM.

Details

Summary

The callback will be invoked in clangd when we receive a reply from the client.

This is a prerequisite of implementing a generic mechanism for chainable refactorings (e.g. extract variable and rename), this would allow server to
trigger a new request to the LSP client after receiving a reply from the client.

Event Timeline

hokein created this revision.Jul 29 2019, 2:45 AM
Herald added a project: Restricted Project. · View Herald TranscriptJul 29 2019, 2:45 AM
ilya-biryukov added a comment.EditedJul 29 2019, 10:08 AM

Could you add a bit more context to the description of the change: why do we need the callback and what is the problem with what we have now?
I'm sure this was discussed offline and the change is justified, just wanted to make sure we write it down in the change history.

clang-tools-extra/clangd/ClangdLSPServer.cpp
405

NIT: simplify to

if (CB)
  return CB(std::move(Result));
// rest of the code.
hokein edited the summary of this revision. (Show Details)Jul 30 2019, 7:06 AM
hokein edited the summary of this revision. (Show Details)

Could you add a bit more context to the description of the change: why do we need the callback and what is the problem with what we have now?
I'm sure this was discussed offline and the change is justified, just wanted to make sure we write it down in the change history.

sorry for missing the context here (updated the description). Yeah, this was a prerequisite of implementing a generic mechanism for chainable refactorings based on the offline discussion.

Hi @hokein,
Do you have any thoughts on how to handle situation when client registers callback but doesn't send a request with registered ID later?

clang-tools-extra/clangd/ClangdLSPServer.cpp
391

Should we care if there's already another callback registered for this StrID?

sammccall added inline comments.Jul 30 2019, 12:33 PM
clang-tools-extra/clangd/ClangdLSPServer.cpp
177

We want to keep logging errors (not just at verbose level).

If you need a non-destructive way to get the error message for logging, I think it's unfortunately:

if (Result) {
  log();
  handle(move(Result));
} else {
  err = Result.takeError();
  log(err);
  handle(move(err));
}

Note I think our logging function has support for printing both rvalue errors (consuming them) and lvalue errors (not consuming them). But failing that, operator<< doesn't consume the error.

388

we know the underlying ID is an integer (because we generate it above), so why not just key on the integer directly?
In Reply, if the ID isn't an integer then we know there's no callback.

391

I think we should assert on this. NextCallID should never overflow.

391

as Jan points out, this is an unbounded leak if the client never replies to requests.

the most obvious approach to fix this is to bound the size and drop e.g. oldest pending query if we overflow. (we can exploit that IDs are sequential integers, if that's useful)

I mention this because if the size is bounded to say 100 and small in practice, you might end up wanting to use deque or (linked) list

407

elog, this is a protocol error

The message is too low level, it describes the implementation rather than the protocol. Prefer e.g. received a reply with ID {0}, but there was no such call

No need to handle error vs non-error here, it should be logged generally above (whether there was a missing callback or not)

576–577

While I like keeping the scope small, it's not really possible to evaluate this change without using it at least once.

Can you use the new facility to address this comment?
This would look something like:

  • ApplyEdit takes a Callback<ApplyWorkspaceEditResponse>
  • callsites look like this
ApplyEdit(*Params.workspaceEdit, bind(std::move(Reply), [](decltype(Reply) Reply, Expected<ApplyWorkspaceEditResponse> Response) {
  if (!Response)
    return Reply(Response.takeError());
  if (!Response->applied)
    return Reply(makeStringError("edits not applied: " + Response->failureReason));
  return Reply("Fix applied");
});

(This is pretty verbose, but seems realistic for what we're actually going to do).

This function is a bit of a complicated monster, so feel free to just handle apply fix, or (better) to factor the code differently so it's reasonable to handle both.

clang-tools-extra/clangd/ClangdLSPServer.h
156

this type is just clangd::Callback<llvm::json::Value>(). (modulo supporting move-only captures, which we want I think)
I'm not sure that's worth abbreviating.

159

the corresponding state for request cancellation lives in MessageHandler, I think this one should too.

Similarly, onReply fits better in MessageHandler than here. call() belongs here though, so I guess call() needs to call something like MessageHandler::bindReply(ID, Callback<T>) or so

161

This API makes the caller of call() responsible for dealing with converting the arbitrary JSON returned by the client into the protocol object, and handling errors (which they'll do inconsistently).

I'd suggest instead a signature like

template <typename Rsp=json::Value>
void call(StringRef Method, json::Value Params, Callback<Rsp> CB=nullptr)

where the template body would wrap CB with conversion and error-handling logic, and then add the "generic" Callback<json::Value> to the map.

you can see bind() - this is similar to how incoming messages are handled.

Making this a template directly forces it to be in the header: to mitigate this you can delegating to a non-template callImpl() as soon as you've erased the type to Callback<llvm::Value>, and/or putting the type-specific call() in MessageHandler (which seems pretty weird to me, so maybe not?)

hokein updated this revision to Diff 212586.Jul 31 2019, 8:13 AM
hokein marked 11 inline comments as done.

address comments:

  • move the reply callback handling code to MessageHandler;
  • prevent memory leakage when LSP clients don't send back the reply;
  • use the new machanism for applyTweak and apply fix;
  • update all relevant tests;
hokein edited the summary of this revision. (Show Details)Jul 31 2019, 8:15 AM
hokein added inline comments.Aug 1 2019, 1:50 AM
clang-tools-extra/clangd/ClangdLSPServer.cpp
288

hmm, we will trigger this assertion when clangd exits but not all requests are replied by the client, options:

  1. remove the assertion here, but the error message "server failed to reply" returned is misleading, in this case, this is a client failure, not server
  2. make sure that all reply callbacks are called, e.g. run the pending reply callback in destructor (returning an LSP error to client)
sammccall added inline comments.Aug 1 2019, 2:31 AM
clang-tools-extra/clangd/ClangdLSPServer.cpp
288

The latter is pretty, but may be complicated (shutdown protocol is messy and involves receiving multiple messages, what happens if a reply arrives in between?)

I would suggest changing the condition here: if the server's destructor has started then don't attempt to reply. You can have an atomic<bool> in ClangdLSPServer that's set at the top of the destructor

hokein updated this revision to Diff 212794.Aug 1 2019, 6:05 AM
hokein marked 2 inline comments as done.
hokein edited the summary of this revision. (Show Details)
  • adjust ReplyOnce assertion logic
  • add more tests
hokein updated this revision to Diff 212796.Aug 1 2019, 6:15 AM

some tweaks

Thanks, this looks a lot better. There are a bunch of details that can be simplified and names that could be clearer, but I think this is the right shape.

clang-tools-extra/clangd/ClangdLSPServer.cpp
188

nit: maybe call this ReplyHandler?

192

this got me thinking - we're using an atomic<int> to assign sequential IDs, but then locking to put them into a map. The reward for using two types of concurrency primitives is our array is almost-but-not-quite sorted.

I think we should demote NextCallID to a regular int, and guard it with the same mutex as ReplyCallbacks. (Just call it CallMutex?) Then bindReply can lock the mutex, pick a call id, add the callback to the map, and return the ID.

I still don't think we need to binary search though. The list is 100 at most and ~1 in practice.

194

nit: a normal loop with if/break seems clearer than find_if here

204

Might just be me, but I think I'd find it slightly to avoid the LogAndRun lambda by overwriting the callback:

// If there was no callback in the table, the client is sending us an unsolicited reply!
if (FoundCB == nullptr)
  Found = [&ID](...) { elog and consume error; }

// inline LogAndRun here
211

elog("more than {0} outstanding LSP calls, forgetting about {1}")

211

nit: we tend to use decltype(Result) to specify the type of Result when we're pseudo-capturing it in a lambda using Bind(). That's not the case here, so please spell out the type.

217

you're returning a string to the callback, I think you meant to return an error?

280–286

Need a comment about the server being destroyed/unreplied calls case.

323

nit: physically this is a deque, but it functions more like a map and I think it might be easier to refer to that way.

326

nit: I think this part of the comment belongs in the implementation rather than on the variable

333

hmm, maybe just use pair<int, Callback<Value>> here? That way it looks even more like a map

579

I can't parse this name.

Looking at the code... it's doing something like ReplyAfterApplyingEdits

It should take the success message as a parameter, I think - at the moment you're replying "fix applied" even in the tweak case

579

So this method is already a tangled mess, and this patch makes it worse. However, it's *conceptually* exactly the right API, just callbacks and Expected and LSP conspire to be awkward here.
We should refactor this, but this patch isn't the right time. Can you add a //FIXME: this method is tangled and confusing, refactor it or so?

590

nit: edits were not applied (or edits failed to apply)

clang-tools-extra/clangd/ClangdLSPServer.h
54

MessageHandler is a nested class and so has access to Destructing already, no need for this function

136

nit: Destroying
but I think IsBeingDestroyed would be clearer, as destroy is transitive

this needs a comment

157

nit: HandleReply?
lambdas often have names like functions, so this reads like it's a function that replies

172

nit: I know I suggested this name but I think callRaw would be better

clang-tools-extra/clangd/Protocol.h
877

needs a default value

= true, I guess

clang-tools-extra/clangd/test/request-reply.test
6

nit, extra line

23

please use increasing IDs and don't reuse them. (In general, but particularly in this test)

24

can you verify that the error comes with the right ID? (and similar below)

hokein updated this revision to Diff 213304.Aug 5 2019, 3:09 AM
hokein marked 24 inline comments as done.

address comments.

hokein added a comment.Aug 5 2019, 3:10 AM

thanks for the detailed comments!

clang-tools-extra/clangd/test/request-reply.test
23

I believe 0 is right here, this is a client reply for the server request 0 (applyEdit).

sammccall accepted this revision.Aug 5 2019, 5:18 AM
sammccall added inline comments.
clang-tools-extra/clangd/ClangdLSPServer.cpp
205

nit: I think this function could return json::Value, encapsulating the fact that we use integers inside the MessageHandler class.

(Or does something outside the class depend on this?)

228

There's no complicated flow control around this - I'd suggest instead leaving ID uninitialized and letting msan flag this if it goes wrong.

280–286

You've added a comment that repeats what the code does, please don't do that :-)

Instead, explain the context around this case. e.g.
"There's one legitimate reason to never reply to a request: clangd's request handler sent a call to the client (e.g. applyWorkspaceEdit) and the client never replied. In this case, the ReplyOnce is owned by ClangdServer's reply callback table and is destroyed along with the server. We don't attempt to send a reply in this case, there's little to be gained from doing so."

321

"held" or "to hold"

325

more specific name: MaxReplyCallbacks or even better MaxOutstandingCalls?

clang-tools-extra/clangd/test/fixits-command.test
206

this ID is reused

clang-tools-extra/clangd/test/request-reply.test
29

ID reuse here (4 was previously used on line 6)

This revision is now accepted and ready to land.Aug 5 2019, 5:18 AM
hokein updated this revision to Diff 213334.Aug 5 2019, 5:35 AM
hokein marked 8 inline comments as done.

address comments.

hokein added inline comments.Aug 5 2019, 5:35 AM
clang-tools-extra/clangd/ClangdLSPServer.cpp
205

we just use it for logging, changing it to Value is totally fine.

clang-tools-extra/clangd/test/fixits-command.test
206

this ID is not reused, it is for server request applyEdit (see line 168).

clang-tools-extra/clangd/test/request-reply.test
29

good catch.

sammccall accepted this revision.Aug 5 2019, 5:44 AM
sammccall marked an inline comment as done.
sammccall added inline comments.
clang-tools-extra/clangd/test/fixits-command.test
206

Doh, sorry :-(

This revision was automatically updated to reflect the committed changes.
Herald added a project: Restricted Project. · View Herald TranscriptAug 5 2019, 5:49 AM
thakis added a subscriber: thakis.Oct 13 2019, 6:01 PM
thakis added inline comments.
clang-tools-extra/trunk/clangd/test/request-reply.test
6 ↗(On Diff #213337)

FYI, referring to a file opened as test:///foo.cpp as file:///clangd-test/file.cpp is wrong on Windows. I fixed this in rL374746.