gopls/internal/cmd: cleanup progress handling

This CL adds connection.registerProgressHandler so that
each call to, say, executeCommand can temporarily install
a handler for progress notifications bearing the token
passed to executeCommand.

However, the initial workspace load must be treated a little
specially since it occurs during the initialize RPC, which
happens before the newConnection constructor has returned.
So, tracking the IWL is now a core feature of connection
instead of something local to the 'stats' subcommand.

Also, make the "asynchronous" property a declarative
attribute of each command.Command so that both the server
and the client can do the right thing--start a thread,
wait for the 'end' notification--based on the metadata.

Also:
- use connection.executeCommand in all cases instead of
  direct call to ExecuteCommand.
- merge Application.connect{,remote} and factor common logic.

Change-Id: If7a000593ef4d4dc5658423a03c56b2f4f3a06ca
Reviewed-on: https://go-review.googlesource.com/c/tools/+/591095
Reviewed-by: Robert Findley <rfindley@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/gopls/doc/commands.md b/gopls/doc/commands.md
index c1c5bea..1e15310 100644
--- a/gopls/doc/commands.md
+++ b/gopls/doc/commands.md
@@ -534,6 +534,8 @@
 
 Run vulnerability check (`govulncheck`).
 
+This command is asynchronous; clients must wait for the 'end' progress notification.
+
 Args:
 
 ```
@@ -560,6 +562,8 @@
 
 Runs `go test` for a specific set of test or benchmark functions.
 
+This command is asynchronous; clients must wait for the 'end' progress notification.
+
 Args:
 
 ```
@@ -665,6 +669,13 @@
 
 Runs `go test` for a specific set of test or benchmark functions.
 
+This command is asynchronous; wait for the 'end' progress notification.
+
+This command is an alias for RunTests; the only difference
+is the form of the parameters.
+
+TODO(adonovan): eliminate it.
+
 Args:
 
 ```
diff --git a/gopls/internal/cmd/call_hierarchy.go b/gopls/internal/cmd/call_hierarchy.go
index 82c18d0..0ac6956 100644
--- a/gopls/internal/cmd/call_hierarchy.go
+++ b/gopls/internal/cmd/call_hierarchy.go
@@ -39,7 +39,7 @@
 		return tool.CommandLineErrorf("call_hierarchy expects 1 argument (position)")
 	}
 
-	conn, err := c.app.connect(ctx, nil)
+	conn, err := c.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/capabilities_test.go b/gopls/internal/cmd/capabilities_test.go
index e043f68..97eb496 100644
--- a/gopls/internal/cmd/capabilities_test.go
+++ b/gopls/internal/cmd/capabilities_test.go
@@ -48,7 +48,7 @@
 
 	// Send an initialize request to the server.
 	ctx := context.Background()
-	client := newClient(app, nil)
+	client := newClient(app)
 	options := settings.DefaultOptions(app.options)
 	server := server.New(cache.NewSession(ctx, cache.New(nil)), client, options)
 	result, err := server.Initialize(ctx, params)
diff --git a/gopls/internal/cmd/check.go b/gopls/internal/cmd/check.go
index 7a0d19f..a859ed2 100644
--- a/gopls/internal/cmd/check.go
+++ b/gopls/internal/cmd/check.go
@@ -51,7 +51,7 @@
 		opts.RelatedInformationSupported = true
 	}
 
-	conn, err := c.app.connect(ctx, nil)
+	conn, err := c.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/cmd.go b/gopls/internal/cmd/cmd.go
index ba3a0b1..b3e5dbe 100644
--- a/gopls/internal/cmd/cmd.go
+++ b/gopls/internal/cmd/cmd.go
@@ -12,6 +12,7 @@
 	"flag"
 	"fmt"
 	"log"
+	"math/rand"
 	"os"
 	"path/filepath"
 	"reflect"
@@ -309,40 +310,31 @@
 )
 
 // connect creates and initializes a new in-process gopls session.
-//
-// If onProgress is set, it is called for each new progress notification.
-func (app *Application) connect(ctx context.Context, onProgress func(*protocol.ProgressParams)) (*connection, error) {
-	switch {
-	case app.Remote == "":
-		client := newClient(app, onProgress)
+func (app *Application) connect(ctx context.Context) (*connection, error) {
+	client := newClient(app)
+	var svr protocol.Server
+	if app.Remote == "" {
+		// local
 		options := settings.DefaultOptions(app.options)
-		server := server.New(cache.NewSession(ctx, cache.New(nil)), client, options)
-		conn := newConnection(server, client)
-		if err := conn.initialize(protocol.WithClient(ctx, client), app.options); err != nil {
+		svr = server.New(cache.NewSession(ctx, cache.New(nil)), client, options)
+		ctx = protocol.WithClient(ctx, client)
+
+	} else {
+		// remote
+		netConn, err := lsprpc.ConnectToRemote(ctx, app.Remote)
+		if err != nil {
 			return nil, err
 		}
-		return conn, nil
-
-	default:
-		return app.connectRemote(ctx, app.Remote)
+		stream := jsonrpc2.NewHeaderStream(netConn)
+		jsonConn := jsonrpc2.NewConn(stream)
+		svr = protocol.ServerDispatcher(jsonConn)
+		ctx = protocol.WithClient(ctx, client)
+		jsonConn.Go(ctx,
+			protocol.Handlers(
+				protocol.ClientHandler(client, jsonrpc2.MethodNotFound)))
 	}
-}
-
-func (app *Application) connectRemote(ctx context.Context, remote string) (*connection, error) {
-	conn, err := lsprpc.ConnectToRemote(ctx, remote)
-	if err != nil {
-		return nil, err
-	}
-	stream := jsonrpc2.NewHeaderStream(conn)
-	cc := jsonrpc2.NewConn(stream)
-	server := protocol.ServerDispatcher(cc)
-	client := newClient(app, nil)
-	connection := newConnection(server, client)
-	ctx = protocol.WithClient(ctx, connection.client)
-	cc.Go(ctx,
-		protocol.Handlers(
-			protocol.ClientHandler(client, jsonrpc2.MethodNotFound)))
-	return connection, connection.initialize(ctx, app.options)
+	conn := newConnection(svr, client)
+	return conn, conn.initialize(ctx, app.options)
 }
 
 func (c *connection) initialize(ctx context.Context, options func(*settings.Options)) error {
@@ -368,12 +360,7 @@
 	params.Capabilities.TextDocument.SemanticTokens.Requests.Full = &protocol.Or_ClientSemanticTokensRequestOptions_full{Value: true}
 	params.Capabilities.TextDocument.SemanticTokens.TokenTypes = protocol.SemanticTypes()
 	params.Capabilities.TextDocument.SemanticTokens.TokenModifiers = protocol.SemanticModifiers()
-
-	// If the subcommand has registered a progress handler, report the progress
-	// capability.
-	if c.client.onProgress != nil {
-		params.Capabilities.Window.WorkDoneProgress = true
-	}
+	params.Capabilities.Window.WorkDoneProgress = true
 
 	params.InitializationOptions = map[string]interface{}{
 		"symbolMatcher": string(opts.SymbolMatcher),
@@ -392,10 +379,35 @@
 	client *cmdClient
 }
 
+// registerProgressHandler registers a handler for progress notifications.
+// The caller must call unregister when the handler is no longer needed.
+func (cli *cmdClient) registerProgressHandler(handler func(*protocol.ProgressParams)) (token protocol.ProgressToken, unregister func()) {
+	token = fmt.Sprintf("tok%d", rand.Uint64())
+
+	// register
+	cli.progressHandlersMu.Lock()
+	if cli.progressHandlers == nil {
+		cli.progressHandlers = make(map[protocol.ProgressToken]func(*protocol.ProgressParams))
+	}
+	cli.progressHandlers[token] = handler
+	cli.progressHandlersMu.Unlock()
+
+	unregister = func() {
+		cli.progressHandlersMu.Lock()
+		delete(cli.progressHandlers, token)
+		cli.progressHandlersMu.Unlock()
+	}
+	return token, unregister
+}
+
 // cmdClient defines the protocol.Client interface behavior of the gopls CLI tool.
 type cmdClient struct {
-	app        *Application
-	onProgress func(*protocol.ProgressParams)
+	app *Application
+
+	progressHandlersMu sync.Mutex
+	progressHandlers   map[protocol.ProgressToken]func(*protocol.ProgressParams)
+	iwlToken           protocol.ProgressToken
+	iwlDone            chan struct{}
 
 	filesMu sync.Mutex // guards files map
 	files   map[protocol.DocumentURI]*cmdFile
@@ -409,11 +421,11 @@
 	diagnostics   []protocol.Diagnostic
 }
 
-func newClient(app *Application, onProgress func(*protocol.ProgressParams)) *cmdClient {
+func newClient(app *Application) *cmdClient {
 	return &cmdClient{
-		app:        app,
-		onProgress: onProgress,
-		files:      make(map[protocol.DocumentURI]*cmdFile),
+		app:     app,
+		files:   make(map[protocol.DocumentURI]*cmdFile),
+		iwlDone: make(chan struct{}),
 	}
 }
 
@@ -674,12 +686,43 @@
 }
 
 func (c *cmdClient) Progress(_ context.Context, params *protocol.ProgressParams) error {
-	if c.onProgress != nil {
-		c.onProgress(params)
+	token, ok := params.Token.(string)
+	if !ok {
+		return fmt.Errorf("unexpected progress token: %[1]T %[1]v", params.Token)
 	}
+
+	c.progressHandlersMu.Lock()
+	handler := c.progressHandlers[token]
+	c.progressHandlersMu.Unlock()
+	if handler == nil {
+		handler = c.defaultProgressHandler
+	}
+	handler(params)
 	return nil
 }
 
+// defaultProgressHandler is the default handler of progress messages,
+// used during the initialize request.
+func (c *cmdClient) defaultProgressHandler(params *protocol.ProgressParams) {
+	switch v := params.Value.(type) {
+	case *protocol.WorkDoneProgressBegin:
+		if v.Title == server.DiagnosticWorkTitle(server.FromInitialWorkspaceLoad) {
+			c.progressHandlersMu.Lock()
+			c.iwlToken = params.Token
+			c.progressHandlersMu.Unlock()
+		}
+
+	case *protocol.WorkDoneProgressEnd:
+		c.progressHandlersMu.Lock()
+		iwlToken := c.iwlToken
+		c.progressHandlersMu.Unlock()
+
+		if params.Token == iwlToken {
+			close(c.iwlDone)
+		}
+	}
+}
+
 func (c *cmdClient) ShowDocument(ctx context.Context, params *protocol.ShowDocumentParams) (*protocol.ShowDocumentResult, error) {
 	var success bool
 	if params.External {
@@ -781,10 +824,7 @@
 	if err != nil {
 		return err
 	}
-	_, err = c.ExecuteCommand(ctx, &protocol.ExecuteCommandParams{
-		Command:   cmd.Command,
-		Arguments: cmd.Arguments,
-	})
+	_, err = c.executeCommand(ctx, &cmd)
 	return err
 }
 
diff --git a/gopls/internal/cmd/codelens.go b/gopls/internal/cmd/codelens.go
index a7017d8..b07f154 100644
--- a/gopls/internal/cmd/codelens.go
+++ b/gopls/internal/cmd/codelens.go
@@ -83,10 +83,7 @@
 		opts.Codelenses[protocol.CodeLensTest] = true
 	}
 
-	// TODO(adonovan): cleanup: factor progress with stats subcommand.
-	cmdDone, onProgress := commandProgress()
-
-	conn, err := r.app.connect(ctx, onProgress)
+	conn, err := r.app.connect(ctx)
 	if err != nil {
 		return err
 	}
@@ -125,7 +122,7 @@
 
 		// -exec: run the first matching code lens.
 		if r.Exec {
-			_, err := conn.executeCommand(ctx, cmdDone, lens.Command)
+			_, err := conn.executeCommand(ctx, lens.Command)
 			return err
 		}
 
diff --git a/gopls/internal/cmd/definition.go b/gopls/internal/cmd/definition.go
index e5e119b..d9cd985 100644
--- a/gopls/internal/cmd/definition.go
+++ b/gopls/internal/cmd/definition.go
@@ -73,7 +73,7 @@
 			o.PreferredContentFormat = protocol.Markdown
 		}
 	}
-	conn, err := d.app.connect(ctx, nil)
+	conn, err := d.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/execute.go b/gopls/internal/cmd/execute.go
index 381c2a7..485e6d0 100644
--- a/gopls/internal/cmd/execute.go
+++ b/gopls/internal/cmd/execute.go
@@ -76,14 +76,13 @@
 
 	e.app.editFlags = &e.EditFlags // in case command performs an edit
 
-	cmdDone, onProgress := commandProgress()
-	conn, err := e.app.connect(ctx, onProgress)
+	conn, err := e.app.connect(ctx)
 	if err != nil {
 		return err
 	}
 	defer conn.terminate(ctx)
 
-	res, err := conn.executeCommand(ctx, cmdDone, &protocol.Command{
+	res, err := conn.executeCommand(ctx, &protocol.Command{
 		Command:   cmd,
 		Arguments: jsonArgs,
 	})
@@ -100,55 +99,40 @@
 	return nil
 }
 
-// -- shared command helpers --
-
-const cmdProgressToken = "cmd-progress"
-
-// TODO(adonovan): disentangle this from app.connect, and factor with
-// conn.executeCommand used by codelens and execute. Seems like
-// connection needs a way to register and unregister independent
-// handlers, later than at connect time.
-func commandProgress() (<-chan bool, func(p *protocol.ProgressParams)) {
-	cmdDone := make(chan bool, 1)
-	onProgress := func(p *protocol.ProgressParams) {
-		switch v := p.Value.(type) {
+// executeCommand executes a protocol.Command, displaying progress
+// messages and awaiting completion of asynchronous commands.
+func (conn *connection) executeCommand(ctx context.Context, cmd *protocol.Command) (any, error) {
+	endStatus := make(chan string, 1)
+	token, unregister := conn.client.registerProgressHandler(func(params *protocol.ProgressParams) {
+		switch v := params.Value.(type) {
 		case *protocol.WorkDoneProgressReport:
-			// TODO(adonovan): how can we segregate command's stdout and
-			// stderr so that structure is preserved?
-			fmt.Fprintln(os.Stderr, v.Message)
+			fmt.Fprintln(os.Stderr, v.Message) // combined std{out,err}
 
 		case *protocol.WorkDoneProgressEnd:
-			if p.Token == cmdProgressToken {
-				// commandHandler.run sends message = canceled | failed | completed
-				cmdDone <- v.Message == server.CommandCompleted
-			}
+			endStatus <- v.Message // = canceled | failed | completed
 		}
-	}
-	return cmdDone, onProgress
-}
+	})
+	defer unregister()
 
-func (conn *connection) executeCommand(ctx context.Context, done <-chan bool, cmd *protocol.Command) (any, error) {
 	res, err := conn.ExecuteCommand(ctx, &protocol.ExecuteCommandParams{
 		Command:   cmd.Command,
 		Arguments: cmd.Arguments,
 		WorkDoneProgressParams: protocol.WorkDoneProgressParams{
-			WorkDoneToken: cmdProgressToken,
+			WorkDoneToken: token,
 		},
 	})
 	if err != nil {
 		return nil, err
 	}
 
-	// Wait for it to finish (by watching for a progress token).
-	//
-	// In theory this is only necessary for the two async
-	// commands (RunGovulncheck and RunTests), but the tests
-	// fail for Test as well (why?), and there is no cost to
-	// waiting in all cases. TODO(adonovan): investigate.
-	if success := <-done; !success {
-		// TODO(adonovan): suppress this message;
-		// the command's stderr should suffice.
-		return nil, fmt.Errorf("command failed")
+	// Some commands are asynchronous, so clients
+	// must wait for the "end" progress notification.
+	enum := command.Command(strings.TrimPrefix(cmd.Command, "gopls."))
+	if enum.IsAsync() {
+		status := <-endStatus
+		if status != server.CommandCompleted {
+			return nil, fmt.Errorf("command %s", status)
+		}
 	}
 
 	return res, nil
diff --git a/gopls/internal/cmd/folding_range.go b/gopls/internal/cmd/folding_range.go
index 13f78c1..f48feee 100644
--- a/gopls/internal/cmd/folding_range.go
+++ b/gopls/internal/cmd/folding_range.go
@@ -36,7 +36,7 @@
 		return tool.CommandLineErrorf("folding_ranges expects 1 argument (file)")
 	}
 
-	conn, err := r.app.connect(ctx, nil)
+	conn, err := r.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/format.go b/gopls/internal/cmd/format.go
index 75982c9..eb68d73 100644
--- a/gopls/internal/cmd/format.go
+++ b/gopls/internal/cmd/format.go
@@ -42,7 +42,7 @@
 		return nil
 	}
 	c.app.editFlags = &c.EditFlags
-	conn, err := c.app.connect(ctx, nil)
+	conn, err := c.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/highlight.go b/gopls/internal/cmd/highlight.go
index 9c1488b..43af063 100644
--- a/gopls/internal/cmd/highlight.go
+++ b/gopls/internal/cmd/highlight.go
@@ -38,7 +38,7 @@
 		return tool.CommandLineErrorf("highlight expects 1 argument (position)")
 	}
 
-	conn, err := r.app.connect(ctx, nil)
+	conn, err := r.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/implementation.go b/gopls/internal/cmd/implementation.go
index fcfb631..8580265 100644
--- a/gopls/internal/cmd/implementation.go
+++ b/gopls/internal/cmd/implementation.go
@@ -39,7 +39,7 @@
 		return tool.CommandLineErrorf("implementation expects 1 argument (position)")
 	}
 
-	conn, err := i.app.connect(ctx, nil)
+	conn, err := i.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/imports.go b/gopls/internal/cmd/imports.go
index 12b49ef..c64c871 100644
--- a/gopls/internal/cmd/imports.go
+++ b/gopls/internal/cmd/imports.go
@@ -43,7 +43,7 @@
 		return tool.CommandLineErrorf("imports expects 1 argument")
 	}
 	t.app.editFlags = &t.EditFlags
-	conn, err := t.app.connect(ctx, nil)
+	conn, err := t.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/links.go b/gopls/internal/cmd/links.go
index 0f1d671..3c14f4e 100644
--- a/gopls/internal/cmd/links.go
+++ b/gopls/internal/cmd/links.go
@@ -44,7 +44,7 @@
 	if len(args) != 1 {
 		return tool.CommandLineErrorf("links expects 1 argument")
 	}
-	conn, err := l.app.connect(ctx, nil)
+	conn, err := l.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/prepare_rename.go b/gopls/internal/cmd/prepare_rename.go
index c7901e6..3ff3835 100644
--- a/gopls/internal/cmd/prepare_rename.go
+++ b/gopls/internal/cmd/prepare_rename.go
@@ -43,7 +43,7 @@
 		return tool.CommandLineErrorf("prepare_rename expects 1 argument (file)")
 	}
 
-	conn, err := r.app.connect(ctx, nil)
+	conn, err := r.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/references.go b/gopls/internal/cmd/references.go
index 3c294c7..1483bf1 100644
--- a/gopls/internal/cmd/references.go
+++ b/gopls/internal/cmd/references.go
@@ -43,7 +43,7 @@
 		return tool.CommandLineErrorf("references expects 1 argument (position)")
 	}
 
-	conn, err := r.app.connect(ctx, nil)
+	conn, err := r.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/rename.go b/gopls/internal/cmd/rename.go
index 6d83168..e96850c 100644
--- a/gopls/internal/cmd/rename.go
+++ b/gopls/internal/cmd/rename.go
@@ -45,7 +45,7 @@
 		return tool.CommandLineErrorf("rename expects 2 arguments (position, new name)")
 	}
 	r.app.editFlags = &r.EditFlags
-	conn, err := r.app.connect(ctx, nil)
+	conn, err := r.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/semantictokens.go b/gopls/internal/cmd/semantictokens.go
index 5729621..77e8a03 100644
--- a/gopls/internal/cmd/semantictokens.go
+++ b/gopls/internal/cmd/semantictokens.go
@@ -71,7 +71,7 @@
 		}
 		opts.SemanticTokens = true
 	}
-	conn, err := c.app.connect(ctx, nil)
+	conn, err := c.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/signature.go b/gopls/internal/cmd/signature.go
index cf976a6..601cfaa 100644
--- a/gopls/internal/cmd/signature.go
+++ b/gopls/internal/cmd/signature.go
@@ -38,7 +38,7 @@
 		return tool.CommandLineErrorf("signature expects 1 argument (position)")
 	}
 
-	conn, err := r.app.connect(ctx, nil)
+	conn, err := r.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/stats.go b/gopls/internal/cmd/stats.go
index 8da1a1a..a788801 100644
--- a/gopls/internal/cmd/stats.go
+++ b/gopls/internal/cmd/stats.go
@@ -16,13 +16,11 @@
 	"reflect"
 	"runtime"
 	"strings"
-	"sync"
 	"time"
 
 	"golang.org/x/tools/gopls/internal/filecache"
 	"golang.org/x/tools/gopls/internal/protocol"
 	"golang.org/x/tools/gopls/internal/protocol/command"
-	"golang.org/x/tools/gopls/internal/server"
 	"golang.org/x/tools/gopls/internal/settings"
 	bugpkg "golang.org/x/tools/gopls/internal/util/bug"
 	versionpkg "golang.org/x/tools/gopls/internal/version"
@@ -85,30 +83,6 @@
 		}
 		o.VerboseWorkDoneProgress = true
 	}
-	var (
-		iwlMu    sync.Mutex
-		iwlToken protocol.ProgressToken
-		iwlDone  = make(chan struct{})
-	)
-
-	onProgress := func(p *protocol.ProgressParams) {
-		switch v := p.Value.(type) {
-		case *protocol.WorkDoneProgressBegin:
-			if v.Title == server.DiagnosticWorkTitle(server.FromInitialWorkspaceLoad) {
-				iwlMu.Lock()
-				iwlToken = p.Token
-				iwlMu.Unlock()
-			}
-		case *protocol.WorkDoneProgressEnd:
-			iwlMu.Lock()
-			tok := iwlToken
-			iwlMu.Unlock()
-
-			if p.Token == tok {
-				close(iwlDone)
-			}
-		}
-	}
 
 	// do executes a timed section of the stats command.
 	do := func(name string, f func() error) (time.Duration, error) {
@@ -123,25 +97,25 @@
 	}
 
 	var conn *connection
-	iwlDuration, err := do("Initializing workspace", func() error {
-		var err error
-		conn, err = s.app.connect(ctx, onProgress)
+	iwlDuration, err := do("Initializing workspace", func() (err error) {
+		conn, err = s.app.connect(ctx)
 		if err != nil {
 			return err
 		}
 		select {
-		case <-iwlDone:
+		case <-conn.client.iwlDone:
 		case <-ctx.Done():
 			return ctx.Err()
 		}
 		return nil
 	})
-	stats.InitialWorkspaceLoadDuration = fmt.Sprint(iwlDuration)
 	if err != nil {
 		return err
 	}
 	defer conn.terminate(ctx)
 
+	stats.InitialWorkspaceLoadDuration = fmt.Sprint(iwlDuration)
+
 	// Gather bug reports produced by any process using
 	// this executable and persisted in the cache.
 	do("Gathering bug reports", func() error {
@@ -153,7 +127,7 @@
 	})
 
 	if _, err := do("Querying memstats", func() error {
-		memStats, err := conn.ExecuteCommand(ctx, &protocol.ExecuteCommandParams{
+		memStats, err := conn.executeCommand(ctx, &protocol.Command{
 			Command: command.MemStats.ID(),
 		})
 		if err != nil {
@@ -166,7 +140,7 @@
 	}
 
 	if _, err := do("Querying workspace stats", func() error {
-		wsStats, err := conn.ExecuteCommand(ctx, &protocol.ExecuteCommandParams{
+		wsStats, err := conn.executeCommand(ctx, &protocol.Command{
 			Command: command.WorkspaceStats.ID(),
 		})
 		if err != nil {
diff --git a/gopls/internal/cmd/suggested_fix.go b/gopls/internal/cmd/suggested_fix.go
index e066460..a326c92 100644
--- a/gopls/internal/cmd/suggested_fix.go
+++ b/gopls/internal/cmd/suggested_fix.go
@@ -75,7 +75,7 @@
 		return tool.CommandLineErrorf("fix expects at least 1 argument")
 	}
 	s.app.editFlags = &s.EditFlags
-	conn, err := s.app.connect(ctx, nil)
+	conn, err := s.app.connect(ctx)
 	if err != nil {
 		return err
 	}
@@ -136,10 +136,7 @@
 		// This may cause the server to make
 		// an ApplyEdit downcall to the client.
 		if a.Command != nil {
-			if _, err := conn.ExecuteCommand(ctx, &protocol.ExecuteCommandParams{
-				Command:   a.Command.Command,
-				Arguments: a.Command.Arguments,
-			}); err != nil {
+			if _, err := conn.executeCommand(ctx, a.Command); err != nil {
 				return err
 			}
 			// The specification says that commands should
diff --git a/gopls/internal/cmd/symbols.go b/gopls/internal/cmd/symbols.go
index 249397d..663a08f 100644
--- a/gopls/internal/cmd/symbols.go
+++ b/gopls/internal/cmd/symbols.go
@@ -36,7 +36,7 @@
 		return tool.CommandLineErrorf("symbols expects 1 argument (position)")
 	}
 
-	conn, err := r.app.connect(ctx, nil)
+	conn, err := r.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/cmd/workspace_symbol.go b/gopls/internal/cmd/workspace_symbol.go
index 9fa7526..aba33fa 100644
--- a/gopls/internal/cmd/workspace_symbol.go
+++ b/gopls/internal/cmd/workspace_symbol.go
@@ -59,7 +59,7 @@
 		}
 	}
 
-	conn, err := r.app.connect(ctx, nil)
+	conn, err := r.app.connect(ctx)
 	if err != nil {
 		return err
 	}
diff --git a/gopls/internal/doc/api.json b/gopls/internal/doc/api.json
index bcb2610..e170385 100644
--- a/gopls/internal/doc/api.json
+++ b/gopls/internal/doc/api.json
@@ -1089,14 +1089,14 @@
 		{
 			"Command": "gopls.run_govulncheck",
 			"Title": "Run vulncheck",
-			"Doc": "Run vulnerability check (`govulncheck`).",
+			"Doc": "Run vulnerability check (`govulncheck`).\n\nThis command is asynchronous; clients must wait for the 'end' progress notification.",
 			"ArgDoc": "{\n\t// Any document in the directory from which govulncheck will run.\n\t\"URI\": string,\n\t// Package pattern. E.g. \"\", \".\", \"./...\".\n\t\"Pattern\": string,\n}",
 			"ResultDoc": "{\n\t// Token holds the progress token for LSP workDone reporting of the vulncheck\n\t// invocation.\n\t\"Token\": interface{},\n}"
 		},
 		{
 			"Command": "gopls.run_tests",
 			"Title": "Run test(s)",
-			"Doc": "Runs `go test` for a specific set of test or benchmark functions.",
+			"Doc": "Runs `go test` for a specific set of test or benchmark functions.\n\nThis command is asynchronous; clients must wait for the 'end' progress notification.",
 			"ArgDoc": "{\n\t// The test file containing the tests to run.\n\t\"URI\": string,\n\t// Specific test names to run, e.g. TestFoo.\n\t\"Tests\": []string,\n\t// Specific benchmarks to run, e.g. BenchmarkFoo.\n\t\"Benchmarks\": []string,\n}",
 			"ResultDoc": ""
 		},
@@ -1124,7 +1124,7 @@
 		{
 			"Command": "gopls.test",
 			"Title": "Run test(s) (legacy)",
-			"Doc": "Runs `go test` for a specific set of test or benchmark functions.",
+			"Doc": "Runs `go test` for a specific set of test or benchmark functions.\n\nThis command is asynchronous; wait for the 'end' progress notification.\n\nThis command is an alias for RunTests; the only difference\nis the form of the parameters.\n\nTODO(adonovan): eliminate it.",
 			"ArgDoc": "string,\n[]string,\n[]string",
 			"ResultDoc": ""
 		},
diff --git a/gopls/internal/protocol/command/interface.go b/gopls/internal/protocol/command/interface.go
index 0f44026..0934cf2 100644
--- a/gopls/internal/protocol/command/interface.go
+++ b/gopls/internal/protocol/command/interface.go
@@ -49,13 +49,20 @@
 	// Test: Run test(s) (legacy)
 	//
 	// Runs `go test` for a specific set of test or benchmark functions.
+	//
+	// This command is asynchronous; wait for the 'end' progress notification.
+	//
+	// This command is an alias for RunTests; the only difference
+	// is the form of the parameters.
+	//
+	// TODO(adonovan): eliminate it.
 	Test(context.Context, protocol.DocumentURI, []string, []string) error
 
-	// TODO: deprecate Test in favor of RunTests below.
-
 	// Test: Run test(s)
 	//
 	// Runs `go test` for a specific set of test or benchmark functions.
+	//
+	// This command is asynchronous; clients must wait for the 'end' progress notification.
 	RunTests(context.Context, RunTestsArgs) error
 
 	// Generate: Run go generate
@@ -178,6 +185,8 @@
 	// RunGovulncheck: Run vulncheck
 	//
 	// Run vulnerability check (`govulncheck`).
+	//
+	// This command is asynchronous; clients must wait for the 'end' progress notification.
 	RunGovulncheck(context.Context, VulncheckArgs) (RunVulncheckResult, error)
 
 	// FetchVulncheckResult: Get known vulncheck result
diff --git a/gopls/internal/protocol/command/util.go b/gopls/internal/protocol/command/util.go
index f49e040..0d9799f 100644
--- a/gopls/internal/protocol/command/util.go
+++ b/gopls/internal/protocol/command/util.go
@@ -9,6 +9,11 @@
 	"fmt"
 )
 
+// TODO(adonovan): use the "gopls."-prefix forms everywhere. The
+// existence of two forms is a nuisance. I think it should be a
+// straightforward fix, as all public APIs and docs use that form
+// already.
+
 // ID returns the command name for use in the LSP.
 func ID(name string) string {
 	return "gopls." + name
@@ -16,6 +21,18 @@
 
 type Command string
 
+// IsAsync reports whether the command is asynchronous:
+// clients must wait for the "end" progress notification.
+func (c Command) IsAsync() bool {
+	switch string(c) {
+	// TODO(adonovan): derive this list from interface.go somewhow.
+	// Unfortunately we can't even reference the enum from here...
+	case "run_tests", "run_govulncheck", "test":
+		return true
+	}
+	return false
+}
+
 // ID returns the command identifier to use in the executeCommand request.
 func (c Command) ID() string {
 	return ID(string(c))
diff --git a/gopls/internal/server/command.go b/gopls/internal/server/command.go
index f7bf1aa..ad2eb95 100644
--- a/gopls/internal/server/command.go
+++ b/gopls/internal/server/command.go
@@ -12,6 +12,7 @@
 	"fmt"
 	"go/ast"
 	"io"
+	"log"
 	"os"
 	"path/filepath"
 	"regexp"
@@ -93,18 +94,8 @@
 
 // commandConfig configures common command set-up and execution.
 type commandConfig struct {
-	// TODO(adonovan): whether a command is synchronous or
-	// asynchronous is part of the server interface contract, not
-	// a mere implementation detail of the handler.
-	// Export a (command.Command).IsAsync() property so that
-	// clients can tell. (The tricky part is ensuring the handler
-	// remains consistent with the command.Command metadata, as at
-	// the point were we read the 'async' field below, we no
-	// longer know that command.Command.)
-
-	async       bool                 // whether to run the command asynchronously. Async commands can only return errors.
 	requireSave bool                 // whether all files must be saved for the command to work
-	progress    string               // title to use for progress reporting. If empty, no progress will be reported.
+	progress    string               // title to use for progress reporting. If empty, no progress will be reported. Mandatory for async commands.
 	forView     string               // view to resolve to a snapshot; incompatible with forURI
 	forURI      protocol.DocumentURI // URI to resolve to a snapshot. If unset, snapshot will be nil.
 }
@@ -115,7 +106,7 @@
 type commandDeps struct {
 	snapshot *cache.Snapshot    // present if cfg.forURI was set
 	fh       file.Handle        // present if cfg.forURI was set
-	work     *progress.WorkDone // present cfg.progress was set
+	work     *progress.WorkDone // present if cfg.progress was set
 }
 
 type commandFunc func(context.Context, commandDeps) error
@@ -195,7 +186,13 @@
 		}
 		return err
 	}
-	if cfg.async {
+
+	enum := command.Command(strings.TrimPrefix(c.params.Command, "gopls."))
+	if enum.IsAsync() {
+		if cfg.progress == "" {
+			log.Fatalf("asynchronous command %q does not enable progress reporting",
+				enum)
+		}
 		go func() {
 			if err := runcmd(); err != nil {
 				showMessage(ctx, c.s.client, protocol.Error, err.Error())
@@ -491,6 +488,7 @@
 	return protocol.EditsFromDiffEdits(pm.Mapper, diff)
 }
 
+// Test is an alias for RunTests (with splayed arguments).
 func (c *commandHandler) Test(ctx context.Context, uri protocol.DocumentURI, tests, benchmarks []string) error {
 	return c.RunTests(ctx, command.RunTestsArgs{
 		URI:        uri,
@@ -583,9 +581,8 @@
 
 func (c *commandHandler) RunTests(ctx context.Context, args command.RunTestsArgs) error {
 	return c.run(ctx, commandConfig{
-		async:       true,
-		progress:    "Running go test",
-		requireSave: true, // go test honors overlays, but tests themselves cannot
+		progress:    "Running go test", // (asynchronous)
+		requireSave: true,              // go test honors overlays, but tests themselves cannot
 		forURI:      args.URI,
 	}, func(ctx context.Context, deps commandDeps) error {
 		return c.runTests(ctx, deps.snapshot, deps.work, args.URI, args.Tests, args.Benchmarks)
@@ -1088,9 +1085,8 @@
 	// synchronize the start of the run and return the token.
 	tokenChan := make(chan protocol.ProgressToken, 1)
 	err := c.run(ctx, commandConfig{
-		async:       true, // need to be async to be cancellable
-		progress:    "govulncheck",
-		requireSave: true, // govulncheck cannot honor overlays
+		progress:    "govulncheck", // (asynchronous)
+		requireSave: true,          // govulncheck cannot honor overlays
 		forURI:      args.URI,
 	}, func(ctx context.Context, deps commandDeps) error {
 		tokenChan <- deps.work.Token()