Skip to content

Commit a199121

Browse files
committed
gopls: allow for asynchronous request handling
As described in golang/go#69937, we need a mechanism that allows for concurrent request handling in gopls. However, this cannot be implemented entirely within the jsonrpc2 layer, because we need gopls to observe requests in the order they arrive, so it can handle them with the correct logical state. This CL adds such a concurrency mechanism using a trick similar to t.Parallel. Specifically, a new jsonrpc2.Async method is introduced which, when invoked on the request context, signals the jsonrpc2.AsyncHandler to start handling the next request. Initially, we use this new mechanism within gopls to allow certain long-running commands to execute asynchronously, once they have acquired a cache.Snapshot representing the current logical state. This solves a long-standing awkwardness in the govulncheck integration, which required an additional gopls.fetch_vulncheck_result command to fetch an asynchronous result. This enables some code deletion and simplification, though we could simplify further. Notably, change the code_action subcommand to eliminate the progress handler registration, since we don't need progress to know when a command is complete. Instead, use -v as a signal to log progress reports to stderr. Fixes golang/go#69937 Change-Id: I8736a445084cfa093f37c479d419294d5a1acbce Reviewed-on: https://go-review.googlesource.com/c/tools/+/621055 Reviewed-by: Alan Donovan <[email protected]> LUCI-TryBot-Result: Go LUCI <[email protected]>
1 parent 8ecf757 commit a199121

File tree

14 files changed

+242
-210
lines changed

14 files changed

+242
-210
lines changed

gopls/internal/cmd/cmd.go

Lines changed: 14 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"flag"
1313
"fmt"
1414
"log"
15-
"math/rand"
1615
"os"
1716
"path/filepath"
1817
"reflect"
@@ -391,35 +390,13 @@ type connection struct {
391390
client *cmdClient
392391
}
393392

394-
// registerProgressHandler registers a handler for progress notifications.
395-
// The caller must call unregister when the handler is no longer needed.
396-
func (cli *cmdClient) registerProgressHandler(handler func(*protocol.ProgressParams)) (token protocol.ProgressToken, unregister func()) {
397-
token = fmt.Sprintf("tok%d", rand.Uint64())
398-
399-
// register
400-
cli.progressHandlersMu.Lock()
401-
if cli.progressHandlers == nil {
402-
cli.progressHandlers = make(map[protocol.ProgressToken]func(*protocol.ProgressParams))
403-
}
404-
cli.progressHandlers[token] = handler
405-
cli.progressHandlersMu.Unlock()
406-
407-
unregister = func() {
408-
cli.progressHandlersMu.Lock()
409-
delete(cli.progressHandlers, token)
410-
cli.progressHandlersMu.Unlock()
411-
}
412-
return token, unregister
413-
}
414-
415393
// cmdClient defines the protocol.Client interface behavior of the gopls CLI tool.
416394
type cmdClient struct {
417395
app *Application
418396

419-
progressHandlersMu sync.Mutex
420-
progressHandlers map[protocol.ProgressToken]func(*protocol.ProgressParams)
421-
iwlToken protocol.ProgressToken
422-
iwlDone chan struct{}
397+
progressMu sync.Mutex
398+
iwlToken protocol.ProgressToken
399+
iwlDone chan struct{}
423400

424401
filesMu sync.Mutex // guards files map
425402
files map[protocol.DocumentURI]*cmdFile
@@ -698,41 +675,33 @@ func (c *cmdClient) PublishDiagnostics(ctx context.Context, p *protocol.PublishD
698675
}
699676

700677
func (c *cmdClient) Progress(_ context.Context, params *protocol.ProgressParams) error {
701-
token, ok := params.Token.(string)
702-
if !ok {
678+
if _, ok := params.Token.(string); !ok {
703679
return fmt.Errorf("unexpected progress token: %[1]T %[1]v", params.Token)
704680
}
705681

706-
c.progressHandlersMu.Lock()
707-
handler := c.progressHandlers[token]
708-
c.progressHandlersMu.Unlock()
709-
if handler == nil {
710-
handler = c.defaultProgressHandler
711-
}
712-
handler(params)
713-
return nil
714-
}
715-
716-
// defaultProgressHandler is the default handler of progress messages,
717-
// used during the initialize request.
718-
func (c *cmdClient) defaultProgressHandler(params *protocol.ProgressParams) {
719682
switch v := params.Value.(type) {
720683
case *protocol.WorkDoneProgressBegin:
721684
if v.Title == server.DiagnosticWorkTitle(server.FromInitialWorkspaceLoad) {
722-
c.progressHandlersMu.Lock()
685+
c.progressMu.Lock()
723686
c.iwlToken = params.Token
724-
c.progressHandlersMu.Unlock()
687+
c.progressMu.Unlock()
688+
}
689+
690+
case *protocol.WorkDoneProgressReport:
691+
if c.app.Verbose {
692+
fmt.Fprintln(os.Stderr, v.Message)
725693
}
726694

727695
case *protocol.WorkDoneProgressEnd:
728-
c.progressHandlersMu.Lock()
696+
c.progressMu.Lock()
729697
iwlToken := c.iwlToken
730-
c.progressHandlersMu.Unlock()
698+
c.progressMu.Unlock()
731699

732700
if params.Token == iwlToken {
733701
close(c.iwlDone)
734702
}
735703
}
704+
return nil
736705
}
737706

738707
func (c *cmdClient) ShowDocument(ctx context.Context, params *protocol.ShowDocumentParams) (*protocol.ShowDocumentResult, error) {

gopls/internal/cmd/execute.go

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,10 @@ import (
1010
"flag"
1111
"fmt"
1212
"log"
13-
"os"
1413
"slices"
1514

1615
"golang.org/x/tools/gopls/internal/protocol"
1716
"golang.org/x/tools/gopls/internal/protocol/command"
18-
"golang.org/x/tools/gopls/internal/server"
1917
"golang.org/x/tools/internal/tool"
2018
)
2119

@@ -98,38 +96,11 @@ func (e *execute) Run(ctx context.Context, args ...string) error {
9896

9997
// executeCommand executes a protocol.Command, displaying progress
10098
// messages and awaiting completion of asynchronous commands.
99+
//
100+
// TODO(rfindley): inline away all calls, ensuring they inline idiomatically.
101101
func (conn *connection) executeCommand(ctx context.Context, cmd *protocol.Command) (any, error) {
102-
endStatus := make(chan string, 1)
103-
token, unregister := conn.client.registerProgressHandler(func(params *protocol.ProgressParams) {
104-
switch v := params.Value.(type) {
105-
case *protocol.WorkDoneProgressReport:
106-
fmt.Fprintln(os.Stderr, v.Message) // combined std{out,err}
107-
108-
case *protocol.WorkDoneProgressEnd:
109-
endStatus <- v.Message // = canceled | failed | completed
110-
}
111-
})
112-
defer unregister()
113-
114-
res, err := conn.ExecuteCommand(ctx, &protocol.ExecuteCommandParams{
102+
return conn.ExecuteCommand(ctx, &protocol.ExecuteCommandParams{
115103
Command: cmd.Command,
116104
Arguments: cmd.Arguments,
117-
WorkDoneProgressParams: protocol.WorkDoneProgressParams{
118-
WorkDoneToken: token,
119-
},
120105
})
121-
if err != nil {
122-
return nil, err
123-
}
124-
125-
// Some commands are asynchronous, so clients
126-
// must wait for the "end" progress notification.
127-
if command.Command(cmd.Command).IsAsync() {
128-
status := <-endStatus
129-
if status != server.CommandCompleted {
130-
return nil, fmt.Errorf("command %s", status)
131-
}
132-
}
133-
134-
return res, nil
135106
}

gopls/internal/cmd/integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ func TestFail(t *testing.T) { t.Fatal("fail") }
224224
}
225225
// run the passing test
226226
{
227-
res := gopls(t, tree, "codelens", "-exec", "./a/a_test.go:3", "run test")
227+
res := gopls(t, tree, "-v", "codelens", "-exec", "./a/a_test.go:3", "run test")
228228
res.checkExit(true)
229229
res.checkStderr(`PASS: TestPass`) // from go test
230230
res.checkStderr("Info: all tests passed") // from gopls.test

gopls/internal/protocol/command/interface.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,15 @@ type VulncheckArgs struct {
503503
type RunVulncheckResult struct {
504504
// Token holds the progress token for LSP workDone reporting of the vulncheck
505505
// invocation.
506+
//
507+
// Deprecated: previously, this was used as a signal to retrieve the result
508+
// using gopls.fetch_vulncheck_result. Clients should ignore this field:
509+
// gopls.vulncheck now runs synchronously, and returns a result in the Result
510+
// field.
506511
Token protocol.ProgressToken
512+
513+
// Result holds the result of running vulncheck.
514+
Result *vulncheck.Result
507515
}
508516

509517
// MemStatsResult holds selected fields from runtime.MemStats.

gopls/internal/protocol/command/util.go

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,6 @@ type Command string
1515

1616
func (c Command) String() string { return string(c) }
1717

18-
// IsAsync reports whether the command is asynchronous:
19-
// clients must wait for the "end" progress notification.
20-
func (c Command) IsAsync() bool {
21-
switch string(c) {
22-
// TODO(adonovan): derive this list from interface.go somewhow.
23-
// Unfortunately we can't even reference the enum from here...
24-
case "gopls.run_tests", "gopls.run_govulncheck", "gopls.test":
25-
return true
26-
}
27-
return false
28-
}
29-
3018
// MarshalArgs encodes the given arguments to json.RawMessages. This function
3119
// is used to construct arguments to a protocol.Command.
3220
//

gopls/internal/server/command.go

Lines changed: 16 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"errors"
1212
"fmt"
1313
"io"
14-
"log"
1514
"os"
1615
"path/filepath"
1716
"regexp"
@@ -41,6 +40,7 @@ import (
4140
"golang.org/x/tools/internal/diff"
4241
"golang.org/x/tools/internal/event"
4342
"golang.org/x/tools/internal/gocommand"
43+
"golang.org/x/tools/internal/jsonrpc2"
4444
"golang.org/x/tools/internal/tokeninternal"
4545
"golang.org/x/tools/internal/xcontext"
4646
)
@@ -278,7 +278,7 @@ func (*commandHandler) AddTelemetryCounters(_ context.Context, args command.AddT
278278
// commandConfig configures common command set-up and execution.
279279
type commandConfig struct {
280280
requireSave bool // whether all files must be saved for the command to work
281-
progress string // title to use for progress reporting. If empty, no progress will be reported. Mandatory for async commands.
281+
progress string // title to use for progress reporting. If empty, no progress will be reported.
282282
forView string // view to resolve to a snapshot; incompatible with forURI
283283
forURI protocol.DocumentURI // URI to resolve to a snapshot. If unset, snapshot will be nil.
284284
}
@@ -370,18 +370,6 @@ func (c *commandHandler) run(ctx context.Context, cfg commandConfig, run command
370370
return err
371371
}
372372

373-
if enum := command.Command(c.params.Command); enum.IsAsync() {
374-
if cfg.progress == "" {
375-
log.Fatalf("asynchronous command %q does not enable progress reporting",
376-
enum)
377-
}
378-
go func() {
379-
if err := runcmd(); err != nil {
380-
showMessage(ctx, c.s.client, protocol.Error, err.Error())
381-
}
382-
}()
383-
return nil
384-
}
385373
return runcmd()
386374
}
387375

@@ -725,6 +713,7 @@ func (c *commandHandler) RunTests(ctx context.Context, args command.RunTestsArgs
725713
requireSave: true, // go test honors overlays, but tests themselves cannot
726714
forURI: args.URI,
727715
}, func(ctx context.Context, deps commandDeps) error {
716+
jsonrpc2.Async(ctx) // don't block RPCs behind this command, since it can take a while
728717
return c.runTests(ctx, deps.snapshot, deps.work, args.URI, args.Tests, args.Benchmarks)
729718
})
730719
}
@@ -1233,23 +1222,25 @@ func (c *commandHandler) FetchVulncheckResult(ctx context.Context, arg command.U
12331222
return ret, err
12341223
}
12351224

1225+
const GoVulncheckCommandTitle = "govulncheck"
1226+
12361227
func (c *commandHandler) RunGovulncheck(ctx context.Context, args command.VulncheckArgs) (command.RunVulncheckResult, error) {
12371228
if args.URI == "" {
12381229
return command.RunVulncheckResult{}, errors.New("VulncheckArgs is missing URI field")
12391230
}
12401231

1241-
// Return the workdone token so that clients can identify when this
1242-
// vulncheck invocation is complete.
1243-
//
1244-
// Since the run function executes asynchronously, we use a channel to
1245-
// synchronize the start of the run and return the token.
1246-
tokenChan := make(chan protocol.ProgressToken, 1)
1232+
var commandResult command.RunVulncheckResult
12471233
err := c.run(ctx, commandConfig{
1248-
progress: "govulncheck", // (asynchronous)
1249-
requireSave: true, // govulncheck cannot honor overlays
1234+
progress: GoVulncheckCommandTitle,
1235+
requireSave: true, // govulncheck cannot honor overlays
12501236
forURI: args.URI,
12511237
}, func(ctx context.Context, deps commandDeps) error {
1252-
tokenChan <- deps.work.Token()
1238+
// For compatibility with the legacy asynchronous API, return the workdone
1239+
// token that clients used to use to identify when this vulncheck
1240+
// invocation is complete.
1241+
commandResult.Token = deps.work.Token()
1242+
1243+
jsonrpc2.Async(ctx) // run this in parallel with other requests: vulncheck can be slow.
12531244

12541245
workDoneWriter := progress.NewWorkDoneWriter(ctx, deps.work)
12551246
dir := filepath.Dir(args.URI.Path())
@@ -1259,6 +1250,7 @@ func (c *commandHandler) RunGovulncheck(ctx context.Context, args command.Vulnch
12591250
if err != nil {
12601251
return err
12611252
}
1253+
commandResult.Result = result
12621254

12631255
snapshot, release, err := c.s.session.InvalidateView(ctx, deps.snapshot.View(), cache.StateChange{
12641256
Vulns: map[protocol.DocumentURI]*vulncheck.Result{args.URI: result},
@@ -1295,12 +1287,7 @@ func (c *commandHandler) RunGovulncheck(ctx context.Context, args command.Vulnch
12951287
if err != nil {
12961288
return command.RunVulncheckResult{}, err
12971289
}
1298-
select {
1299-
case <-ctx.Done():
1300-
return command.RunVulncheckResult{}, ctx.Err()
1301-
case token := <-tokenChan:
1302-
return command.RunVulncheckResult{Token: token}, nil
1303-
}
1290+
return commandResult, nil
13041291
}
13051292

13061293
// MemStats implements the MemStats command. It returns an error as a

gopls/internal/test/integration/codelens/codelens_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,10 @@ require golang.org/x/hello v1.2.3
182182
if !found {
183183
t.Fatalf("found no command with the title %s", commandTitle)
184184
}
185-
if _, err := env.Editor.ExecuteCommand(env.Ctx, &protocol.ExecuteCommandParams{
185+
if err := env.Editor.ExecuteCommand(env.Ctx, &protocol.ExecuteCommandParams{
186186
Command: lens.Command.Command,
187187
Arguments: lens.Command.Arguments,
188-
}); err != nil {
188+
}, nil); err != nil {
189189
t.Fatal(err)
190190
}
191191
env.AfterChange()

gopls/internal/test/integration/expectation.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -452,17 +452,27 @@ type WorkStatus struct {
452452
EndMsg string
453453
}
454454

455-
// CompletedProgress expects that workDone progress is complete for the given
456-
// progress token. When non-nil WorkStatus is provided, it will be filled
457-
// when the expectation is met.
455+
// CompletedProgress expects that there is exactly one workDone progress with
456+
// the given title, and is satisfied when that progress completes. If it is
457+
// met, the corresponding status is written to the into argument.
458458
//
459-
// If the token is not a progress token that the client has seen, this
460-
// expectation is Unmeetable.
461-
func CompletedProgress(token protocol.ProgressToken, into *WorkStatus) Expectation {
459+
// TODO(rfindley): refactor to eliminate the redundancy with CompletedWork.
460+
// This expectation is a vestige of older workarounds for asynchronous command
461+
// execution.
462+
func CompletedProgress(title string, into *WorkStatus) Expectation {
462463
check := func(s State) Verdict {
463-
work, ok := s.work[token]
464-
if !ok {
465-
return Unmeetable // TODO(rfindley): refactor to allow the verdict to explain this result
464+
var work *workProgress
465+
for _, w := range s.work {
466+
if w.title == title {
467+
if work != nil {
468+
// TODO(rfindley): refactor to allow the verdict to explain this result
469+
return Unmeetable // multiple matches
470+
}
471+
work = w
472+
}
473+
}
474+
if work == nil {
475+
return Unmeetable // zero matches
466476
}
467477
if work.complete {
468478
if into != nil {
@@ -473,7 +483,7 @@ func CompletedProgress(token protocol.ProgressToken, into *WorkStatus) Expectati
473483
}
474484
return Unmet
475485
}
476-
desc := fmt.Sprintf("completed work for token %v", token)
486+
desc := fmt.Sprintf("exactly 1 completed workDoneProgress with title %v", title)
477487
return Expectation{
478488
Check: check,
479489
Description: desc,

0 commit comments

Comments
 (0)