gopls/internal/regtest/bench: support benchmarking multiple repos

Extract benchmark state into a new repo type, so that we may run
benchmarks in multiple shared workspaces. Also, add missing cleanup
code.

Additionally, simplify to always run gopls in a separate process. This
means that the normal test profiling flags won't be useful, so add
support for threading through profiling flags to the external gopls
process.

For golang/go#53538

Change-Id: Ib9ab5920dc59f102c62b53b761379dd8ca2d7141
Reviewed-on: https://go-review.googlesource.com/c/tools/+/468940
TryBot-Result: Gopher Robot <gobot@golang.org>
gopls-CI: kokoro <noreply+kokoro@google.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
Run-TryBot: Robert Findley <rfindley@google.com>
diff --git a/gopls/internal/regtest/bench/bench_test.go b/gopls/internal/regtest/bench/bench_test.go
index e285aa5..fa06e27 100644
--- a/gopls/internal/regtest/bench/bench_test.go
+++ b/gopls/internal/regtest/bench/bench_test.go
@@ -18,10 +18,8 @@
 	"time"
 
 	"golang.org/x/tools/gopls/internal/hooks"
-	"golang.org/x/tools/gopls/internal/lsp/cache"
 	"golang.org/x/tools/gopls/internal/lsp/cmd"
 	"golang.org/x/tools/gopls/internal/lsp/fake"
-	"golang.org/x/tools/gopls/internal/lsp/lsprpc"
 	"golang.org/x/tools/internal/bug"
 	"golang.org/x/tools/internal/event"
 	"golang.org/x/tools/internal/fakenet"
@@ -32,15 +30,21 @@
 	. "golang.org/x/tools/gopls/internal/lsp/regtest"
 )
 
-// This package implements benchmarks that share a common editor session.
-//
-// It is a work-in-progress.
-//
-// Remaining TODO(rfindley):
-//   - add detailed documentation for how to write a benchmark, as a package doc
-//   - add benchmarks for more features
-//   - eliminate flags, and just run benchmarks on with a predefined set of
-//     arguments
+var (
+	goplsPath = flag.String("gopls_path", "", "if set, use this gopls for testing; incompatible with -gopls_commit")
+
+	installGoplsOnce sync.Once // guards installing gopls at -gopls_commit
+	goplsCommit      = flag.String("gopls_commit", "", "if set, install and use gopls at this commit for testing; incompatible with -gopls_path")
+
+	cpuProfile = flag.String("gopls_cpuprofile", "", "if set, the cpu profile file suffix; see \"Profiling\" in the package doc")
+	memProfile = flag.String("gopls_memprofile", "", "if set, the mem profile file suffix; see \"Profiling\" in the package doc")
+	trace      = flag.String("gopls_trace", "", "if set, the trace file suffix; see \"Profiling\" in the package doc")
+
+	// If non-empty, tempDir is a temporary working dir that was created by this
+	// test suite.
+	makeTempDirOnce sync.Once // guards creation of the temp dir
+	tempDir         string
+)
 
 // if runAsGopls is "true", run the gopls command instead of the testing.M.
 const runAsGopls = "_GOPLS_BENCH_RUN_AS_GOPLS"
@@ -52,56 +56,16 @@
 		os.Exit(0)
 	}
 	event.SetExporter(nil) // don't log to stderr
-	code := doMain(m)
+	code := m.Run()
+	if err := cleanup(); err != nil {
+		fmt.Fprintf(os.Stderr, "cleaning up after benchmarks: %v\n", err)
+		if code == 0 {
+			code = 1
+		}
+	}
 	os.Exit(code)
 }
 
-func doMain(m *testing.M) (code int) {
-	defer func() {
-		if editor != nil {
-			if err := editor.Close(context.Background()); err != nil {
-				fmt.Fprintf(os.Stderr, "closing editor: %v", err)
-				if code == 0 {
-					code = 1
-				}
-			}
-		}
-		if tempDir != "" {
-			if err := os.RemoveAll(tempDir); err != nil {
-				fmt.Fprintf(os.Stderr, "cleaning temp dir: %v", err)
-				if code == 0 {
-					code = 1
-				}
-			}
-		}
-	}()
-	return m.Run()
-}
-
-var (
-	workdir   = flag.String("workdir", "", "if set, working directory to use for benchmarks; overrides -repo and -commit")
-	repo      = flag.String("repo", "https://go.googlesource.com/tools", "if set (and -workdir is unset), run benchmarks in this repo")
-	file      = flag.String("file", "go/ast/astutil/util.go", "active file, for benchmarks that operate on a file")
-	commitish = flag.String("commit", "gopls/v0.9.0", "if set (and -workdir is unset), run benchmarks at this commit")
-
-	goplsPath   = flag.String("gopls_path", "", "if set, use this gopls for testing; incompatible with -gopls_commit")
-	goplsCommit = flag.String("gopls_commit", "", "if set, install and use gopls at this commit for testing; incompatible with -gopls_path")
-
-	// If non-empty, tempDir is a temporary working dir that was created by this
-	// test suite.
-	//
-	// The sync.Once variables guard various modifications of the temp directory.
-	makeTempDirOnce  sync.Once
-	checkoutRepoOnce sync.Once
-	installGoplsOnce sync.Once
-	tempDir          string
-
-	setupEditorOnce sync.Once
-	sandbox         *fake.Sandbox
-	editor          *fake.Editor
-	awaiter         *Awaiter
-)
-
 // getTempDir returns the temporary directory to use for benchmark files,
 // creating it if necessary.
 func getTempDir() string {
@@ -115,31 +79,6 @@
 	return tempDir
 }
 
-// benchmarkDir returns the directory to use for benchmarks.
-//
-// If -workdir is set, just use that directory. Otherwise, check out a shallow
-// copy of -repo at the given -commit, and clean up when the test suite exits.
-func benchmarkDir() string {
-	if *workdir != "" {
-		return *workdir
-	}
-	if *repo == "" {
-		log.Fatal("-repo must be provided if -workdir is unset")
-	}
-	if *commitish == "" {
-		log.Fatal("-commit must be provided if -workdir is unset")
-	}
-
-	dir := filepath.Join(getTempDir(), "repo")
-	checkoutRepoOnce.Do(func() {
-		log.Printf("creating working dir: checking out %s@%s to %s\n", *repo, *commitish, dir)
-		if err := shallowClone(dir, *repo, *commitish); err != nil {
-			log.Fatal(err)
-		}
-	})
-	return dir
-}
-
 // shallowClone performs a shallow clone of repo into dir at the given
 // 'commitish' ref (any commit reference understood by git).
 //
@@ -163,70 +102,6 @@
 	return nil
 }
 
-// sharedEnv returns a shared benchmark environment.
-//
-// Every call to sharedEnv uses the same editor and sandbox. If -gopls_path and
-// -gopls_commit are unset, this environment will run gopls in-process.
-func sharedEnv(tb testing.TB) *Env {
-	setupEditorOnce.Do(func() {
-		dir := benchmarkDir()
-
-		var err error
-		ts := getServer()
-		sandbox, editor, awaiter, err = connectEditor(dir, fake.EditorConfig{}, ts)
-		if err != nil {
-			log.Fatalf("connecting editor: %v", err)
-		}
-
-		if err := awaiter.Await(context.Background(), InitialWorkspaceLoad); err != nil {
-			panic(err)
-		}
-	})
-
-	return &Env{
-		T:       tb,
-		Ctx:     context.Background(),
-		Editor:  editor,
-		Sandbox: sandbox,
-		Awaiter: awaiter,
-	}
-}
-
-// newEnv returns a new Env connected to separate gopls process communicating
-// over stdin/stdout.
-//
-// Every call to newEnv returns a different Env connected to a distinct gopls
-// process.
-//
-// TODO(rfindley): consolidate gopls server construction: always use a sidecar,
-// and make it easy to collect profiles.
-func newEnv(dir string, tb testing.TB) *Env {
-	goplsPath := getGoplsPath()
-	if goplsPath == "" {
-		var err error
-		goplsPath, err = os.Executable()
-		if err != nil {
-			tb.Fatal(err)
-		}
-	}
-	ts := &SidecarServer{
-		goplsPath: goplsPath,
-		env:       []string{fmt.Sprintf("%s=true", runAsGopls)},
-	}
-	server, editor, awaiter, err := connectEditor(dir, fake.EditorConfig{}, ts)
-	if err != nil {
-		tb.Fatalf("connecting editor: %v", err)
-	}
-
-	return &Env{
-		T:       tb,
-		Ctx:     context.Background(),
-		Editor:  editor,
-		Sandbox: server,
-		Awaiter: awaiter,
-	}
-}
-
 // connectEditor connects a fake editor session in the given dir, using the
 // given editor config.
 func connectEditor(dir string, config fake.EditorConfig, ts servertest.Connector) (*fake.Sandbox, *fake.Editor, *Awaiter, error) {
@@ -246,30 +121,41 @@
 	return s, e, a, nil
 }
 
-// getServer returns a server connector that either starts a new in-process
-// server, or starts a separate gopls process.
-func getServer() servertest.Connector {
+// newGoplsServer returns a connector that connects to a new gopls process.
+func newGoplsServer(name string) (servertest.Connector, error) {
 	if *goplsPath != "" && *goplsCommit != "" {
 		panic("can't set both -gopls_path and -gopls_commit")
 	}
-	if path := getGoplsPath(); path != "" {
-		return &SidecarServer{goplsPath: *goplsPath}
-	}
-	server := lsprpc.NewStreamServer(cache.New(nil, nil), false, hooks.Options)
-	return servertest.NewPipeServer(server, jsonrpc2.NewRawStream)
-}
-
-// getGoplsPath returns the path to the external gopls binary to use for
-// benchmarks, or the empty string if no external gopls is configured via
-// -gopls_path or -gopls_commit.
-func getGoplsPath() string {
-	if *goplsPath != "" {
-		return *goplsPath
-	}
+	var (
+		goplsPath = *goplsPath
+		env       []string
+	)
 	if *goplsCommit != "" {
-		return getInstalledGopls()
+		goplsPath = getInstalledGopls()
 	}
-	return ""
+	if goplsPath == "" {
+		var err error
+		goplsPath, err = os.Executable()
+		if err != nil {
+			return nil, err
+		}
+		env = []string{fmt.Sprintf("%s=true", runAsGopls)}
+	}
+	var args []string
+	if *cpuProfile != "" {
+		args = append(args, fmt.Sprintf("-profile.cpu=%s", name+"."+*cpuProfile))
+	}
+	if *memProfile != "" {
+		args = append(args, fmt.Sprintf("-profile.mem=%s", name+"."+*memProfile))
+	}
+	if *trace != "" {
+		args = append(args, fmt.Sprintf("-profile.trace=%s", name+"."+*trace))
+	}
+	return &SidecarServer{
+		goplsPath: goplsPath,
+		env:       env,
+		args:      args,
+	}, nil
 }
 
 // getInstalledGopls builds gopls at the given -gopls_commit, returning the
@@ -307,11 +193,18 @@
 type SidecarServer struct {
 	goplsPath string
 	env       []string // additional environment bindings
+	args      []string // command-line arguments
 }
 
 // Connect creates new io.Pipes and binds them to the underlying StreamServer.
+//
+// It implements the servertest.Connector interface.
 func (s *SidecarServer) Connect(ctx context.Context) jsonrpc2.Conn {
-	cmd := exec.CommandContext(ctx, s.goplsPath, "serve")
+	// Note: don't use CommandContext here, as we want gopls to exit gracefully
+	// in order to write out profile data.
+	//
+	// We close the connection on context cancelation below.
+	cmd := exec.Command(s.goplsPath, s.args...)
 
 	stdin, err := cmd.StdinPipe()
 	if err != nil {
@@ -321,15 +214,34 @@
 	if err != nil {
 		log.Fatal(err)
 	}
-	cmd.Stderr = os.Stdout
+	cmd.Stderr = os.Stderr
 	cmd.Env = append(os.Environ(), s.env...)
 	if err := cmd.Start(); err != nil {
 		log.Fatalf("starting gopls: %v", err)
 	}
 
-	go cmd.Wait() // to free resources; error is ignored
+	go func() {
+		// If we don't log.Fatal here, benchmarks may hang indefinitely if gopls
+		// exits abnormally.
+		//
+		// TODO(rfindley): ideally we would shut down the connection gracefully,
+		// but that doesn't currently work.
+		if err := cmd.Wait(); err != nil {
+			log.Fatalf("gopls invocation failed with error: %v", err)
+		}
+	}()
 
 	clientStream := jsonrpc2.NewHeaderStream(fakenet.NewConn("stdio", stdout, stdin))
 	clientConn := jsonrpc2.NewConn(clientStream)
+
+	go func() {
+		select {
+		case <-ctx.Done():
+			clientConn.Close()
+			clientStream.Close()
+		case <-clientConn.Done():
+		}
+	}()
+
 	return clientConn
 }
diff --git a/gopls/internal/regtest/bench/completion_test.go b/gopls/internal/regtest/bench/completion_test.go
index f597ab9..a89a4ff 100644
--- a/gopls/internal/regtest/bench/completion_test.go
+++ b/gopls/internal/regtest/bench/completion_test.go
@@ -5,14 +5,11 @@
 package bench
 
 import (
-	"context"
 	"fmt"
 	"testing"
 
 	"golang.org/x/tools/gopls/internal/lsp/protocol"
 	. "golang.org/x/tools/gopls/internal/lsp/regtest"
-
-	"golang.org/x/tools/gopls/internal/lsp/fake"
 )
 
 type completionBenchOptions struct {
@@ -24,32 +21,9 @@
 }
 
 func benchmarkCompletion(options completionBenchOptions, b *testing.B) {
-	dir := benchmarkDir()
-
-	// Use a new environment for each test, to avoid any existing state from the
-	// previous session.
-	sandbox, editor, awaiter, err := connectEditor(dir, fake.EditorConfig{
-		Settings: map[string]interface{}{
-			"completionBudget": "1m", // arbitrary long completion budget
-		},
-	}, getServer())
-	if err != nil {
-		b.Fatal(err)
-	}
-	ctx := context.Background()
-	defer func() {
-		if err := editor.Close(ctx); err != nil {
-			b.Errorf("closing editor: %v", err)
-		}
-	}()
-
-	env := &Env{
-		T:       b,
-		Ctx:     ctx,
-		Editor:  editor,
-		Sandbox: sandbox,
-		Awaiter: awaiter,
-	}
+	repo := repos["tools"]
+	env := repo.newEnv(b)
+	defer env.Close()
 
 	// Run edits required for this completion.
 	if options.setup != nil {
diff --git a/gopls/internal/regtest/bench/definition_test.go b/gopls/internal/regtest/bench/definition_test.go
index 20b75de..cdffcf6 100644
--- a/gopls/internal/regtest/bench/definition_test.go
+++ b/gopls/internal/regtest/bench/definition_test.go
@@ -4,10 +4,12 @@
 
 package bench
 
-import "testing"
+import (
+	"testing"
+)
 
-func BenchmarkGoToDefinition(b *testing.B) {
-	env := sharedEnv(b)
+func BenchmarkDefinition(b *testing.B) {
+	env := repos["tools"].sharedEnv(b)
 
 	env.OpenFile("internal/imports/mod.go")
 	loc := env.RegexpSearch("internal/imports/mod.go", "ModuleJSON")
diff --git a/gopls/internal/regtest/bench/didchange_test.go b/gopls/internal/regtest/bench/didchange_test.go
index 4e6bd23..e18ad4e 100644
--- a/gopls/internal/regtest/bench/didchange_test.go
+++ b/gopls/internal/regtest/bench/didchange_test.go
@@ -18,16 +18,17 @@
 //
 // Uses -workdir and -file to control where the edits occur.
 func BenchmarkDidChange(b *testing.B) {
-	env := sharedEnv(b)
-	env.OpenFile(*file)
-	env.Await(env.DoneWithOpen())
+	env := repos["tools"].sharedEnv(b)
+	const filename = "go/ast/astutil/util.go"
+	env.OpenFile(filename)
+	env.AfterChange()
 
 	// Insert the text we'll be modifying at the top of the file.
-	env.EditBuffer(*file, protocol.TextEdit{NewText: "// __REGTEST_PLACEHOLDER_0__\n"})
+	env.EditBuffer(filename, protocol.TextEdit{NewText: "// __REGTEST_PLACEHOLDER_0__\n"})
 
 	b.ResetTimer()
 	for i := 0; i < b.N; i++ {
-		env.EditBuffer(*file, protocol.TextEdit{
+		env.EditBuffer(filename, protocol.TextEdit{
 			Range: protocol.Range{
 				Start: protocol.Position{Line: 0, Character: 0},
 				End:   protocol.Position{Line: 1, Character: 0},
diff --git a/gopls/internal/regtest/bench/doc.go b/gopls/internal/regtest/bench/doc.go
new file mode 100644
index 0000000..a9f2fbf
--- /dev/null
+++ b/gopls/internal/regtest/bench/doc.go
@@ -0,0 +1,33 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// The bench package implements benchmarks for various LSP operations.
+//
+// Benchmarks check out specific commits of popular and/or exemplary
+// repositories, and script an external gopls process via a fake text editor.
+// By default, benchmarks run the test executable as gopls (using a special
+// "gopls mode" environment variable). A different gopls binary may be used by
+// setting the -gopls_path or -gopls_commit flags.
+//
+// This package is a work in progress.
+//
+// # Profiling
+//
+// As benchmark functions run gopls in a separate process, the normal test
+// flags for profiling are not useful. Instead the -gopls_cpuprofile,
+// -gopls_memprofile, and -gopls_trace flags may be used to pass through
+// profiling flags to the gopls process. Each of these flags sets a suffix
+// for the respective gopls profiling flag, which is prefixed with a name
+// corresponding to the shared repository or (in some cases) benchmark name.
+// For example, settings -gopls_cpuprofile=cpu.out will result in profiles
+// named tools.cpu.out, BenchmarkInitialWorkspaceLoad.cpu.out, etc. Here,
+// tools.cpu.out is the cpu profile for the shared x/tools session, which may
+// be used by multiple benchmark functions, and BenchmarkInitialWorkspaceLoad
+// is the cpu profile for the last iteration of the initial workspace load
+// test, which starts a new editor session for each iteration.
+//
+// # TODO
+//   - add more benchmarks, and more repositories
+//   - improve this documentation
+package bench
diff --git a/gopls/internal/regtest/bench/hover_test.go b/gopls/internal/regtest/bench/hover_test.go
index 78fdc93..ebd89b8 100644
--- a/gopls/internal/regtest/bench/hover_test.go
+++ b/gopls/internal/regtest/bench/hover_test.go
@@ -9,7 +9,7 @@
 )
 
 func BenchmarkHover(b *testing.B) {
-	env := sharedEnv(b)
+	env := repos["tools"].sharedEnv(b)
 
 	env.OpenFile("internal/imports/mod.go")
 	loc := env.RegexpSearch("internal/imports/mod.go", "bytes")
diff --git a/gopls/internal/regtest/bench/implementations_test.go b/gopls/internal/regtest/bench/implementations_test.go
index 610a2d2..7f83987 100644
--- a/gopls/internal/regtest/bench/implementations_test.go
+++ b/gopls/internal/regtest/bench/implementations_test.go
@@ -6,8 +6,8 @@
 
 import "testing"
 
-func BenchmarkFindAllImplementations(b *testing.B) {
-	env := sharedEnv(b)
+func BenchmarkImplementations(b *testing.B) {
+	env := repos["tools"].sharedEnv(b)
 
 	env.OpenFile("internal/imports/mod.go")
 	loc := env.RegexpSearch("internal/imports/mod.go", "initAllMods")
diff --git a/gopls/internal/regtest/bench/iwl_test.go b/gopls/internal/regtest/bench/iwl_test.go
index 87df199..44cdc78 100644
--- a/gopls/internal/regtest/bench/iwl_test.go
+++ b/gopls/internal/regtest/bench/iwl_test.go
@@ -15,12 +15,16 @@
 // BenchmarkInitialWorkspaceLoad benchmarks the initial workspace load time for
 // a new editing session.
 func BenchmarkInitialWorkspaceLoad(b *testing.B) {
-	dir := benchmarkDir()
+	repo := repos["tools"]
 	b.ResetTimer()
 
 	for i := 0; i < b.N; i++ {
-		env := newEnv(dir, b)
-		// TODO(rfindley): this depends on the repository being x/tools. Fix this.
+		// Exclude the time to set up the env from the benchmark time, as this may
+		// involve installing gopls and/or checking out the repo dir.
+		b.StopTimer()
+		env := repo.newEnv(b)
+		b.StartTimer()
+
 		env.OpenFile("internal/lsp/diagnostics.go")
 		env.Await(InitialWorkspaceLoad)
 		b.StopTimer()
diff --git a/gopls/internal/regtest/bench/references_test.go b/gopls/internal/regtest/bench/references_test.go
index e5f1f63..7822750 100644
--- a/gopls/internal/regtest/bench/references_test.go
+++ b/gopls/internal/regtest/bench/references_test.go
@@ -7,7 +7,7 @@
 import "testing"
 
 func BenchmarkReferences(b *testing.B) {
-	env := sharedEnv(b)
+	env := repos["tools"].sharedEnv(b)
 
 	env.OpenFile("internal/imports/mod.go")
 	loc := env.RegexpSearch("internal/imports/mod.go", "gopathwalk")
diff --git a/gopls/internal/regtest/bench/rename_test.go b/gopls/internal/regtest/bench/rename_test.go
index e6db663..7339c76 100644
--- a/gopls/internal/regtest/bench/rename_test.go
+++ b/gopls/internal/regtest/bench/rename_test.go
@@ -10,7 +10,7 @@
 )
 
 func BenchmarkRename(b *testing.B) {
-	env := sharedEnv(b)
+	env := repos["tools"].sharedEnv(b)
 
 	env.OpenFile("internal/imports/mod.go")
 	env.Await(env.DoneWithOpen())
diff --git a/gopls/internal/regtest/bench/repo_test.go b/gopls/internal/regtest/bench/repo_test.go
new file mode 100644
index 0000000..9b7ce72
--- /dev/null
+++ b/gopls/internal/regtest/bench/repo_test.go
@@ -0,0 +1,164 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package bench
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"log"
+	"os"
+	"path/filepath"
+	"sync"
+	"testing"
+
+	"golang.org/x/tools/gopls/internal/lsp/fake"
+	. "golang.org/x/tools/gopls/internal/lsp/regtest"
+)
+
+// repos holds shared repositories for use in benchmarks.
+var repos = map[string]*repo{
+	"tools": {name: "tools", url: "https://go.googlesource.com/tools", commit: "gopls/v0.9.0"},
+}
+
+// A repo represents a working directory for a repository checked out at a
+// specific commit.
+//
+// Repos are used for sharing state across benchmarks that operate on the same
+// codebase.
+type repo struct {
+	// static configuration
+	name   string // must be unique, used for subdirectory
+	url    string // repo url
+	commit string // commitish, e.g. tag or short commit hash
+
+	dirOnce sync.Once
+	dir     string // directory contaning source code checked out to url@commit
+
+	// shared editor state
+	editorOnce sync.Once
+	editor     *fake.Editor
+	sandbox    *fake.Sandbox
+	awaiter    *Awaiter
+}
+
+// getDir returns directory containing repo source code, creating it if
+// necessary. It is safe for concurrent use.
+func (r *repo) getDir() string {
+	r.dirOnce.Do(func() {
+		r.dir = filepath.Join(getTempDir(), r.name)
+		log.Printf("cloning %s@%s into %s", r.url, r.commit, r.dir)
+		if err := shallowClone(r.dir, r.url, r.commit); err != nil {
+			log.Fatal(err)
+		}
+	})
+	return r.dir
+}
+
+// sharedEnv returns a shared benchmark environment. It is safe for concurrent
+// use.
+//
+// Every call to sharedEnv uses the same editor and sandbox, as a means to
+// avoid reinitializing the editor for large repos. Calling repo.Close cleans
+// up the shared environment.
+//
+// Repos in the package-local Repos var are closed at the end of the test main
+// function.
+func (r *repo) sharedEnv(tb testing.TB) *Env {
+	r.editorOnce.Do(func() {
+		dir := r.getDir()
+
+		ts, err := newGoplsServer(r.name)
+		if err != nil {
+			log.Fatal(err)
+		}
+		r.sandbox, r.editor, r.awaiter, err = connectEditor(dir, fake.EditorConfig{}, ts)
+		if err != nil {
+			log.Fatalf("connecting editor: %v", err)
+		}
+
+		if err := r.awaiter.Await(context.Background(), InitialWorkspaceLoad); err != nil {
+			log.Fatal(err)
+		}
+	})
+
+	return &Env{
+		T:       tb,
+		Ctx:     context.Background(),
+		Editor:  r.editor,
+		Sandbox: r.sandbox,
+		Awaiter: r.awaiter,
+	}
+}
+
+// newEnv returns a new Env connected to a new gopls process communicating
+// over stdin/stdout. It is safe for concurrent use.
+//
+// It is the caller's responsibility to call Close on the resulting Env when it
+// is no longer needed.
+func (r *repo) newEnv(tb testing.TB) *Env {
+	dir := r.getDir()
+
+	ts, err := newGoplsServer(tb.Name())
+	if err != nil {
+		tb.Fatal(err)
+	}
+	sandbox, editor, awaiter, err := connectEditor(dir, fake.EditorConfig{}, ts)
+	if err != nil {
+		log.Fatalf("connecting editor: %v", err)
+	}
+
+	return &Env{
+		T:       tb,
+		Ctx:     context.Background(),
+		Editor:  editor,
+		Sandbox: sandbox,
+		Awaiter: awaiter,
+	}
+}
+
+// Close cleans up shared state referenced by the repo.
+func (r *repo) Close() error {
+	var errBuf bytes.Buffer
+	if r.editor != nil {
+		if err := r.editor.Close(context.Background()); err != nil {
+			fmt.Fprintf(&errBuf, "closing editor: %v", err)
+		}
+	}
+	if r.sandbox != nil {
+		if err := r.sandbox.Close(); err != nil {
+			fmt.Fprintf(&errBuf, "closing sandbox: %v", err)
+		}
+	}
+	if r.dir != "" {
+		if err := os.RemoveAll(r.dir); err != nil {
+			fmt.Fprintf(&errBuf, "cleaning dir: %v", err)
+		}
+	}
+	if errBuf.Len() > 0 {
+		return errors.New(errBuf.String())
+	}
+	return nil
+}
+
+// cleanup cleans up state that is shared across benchmark functions.
+func cleanup() error {
+	var errBuf bytes.Buffer
+	for _, repo := range repos {
+		if err := repo.Close(); err != nil {
+			fmt.Fprintf(&errBuf, "closing %q: %v", repo.name, err)
+		}
+	}
+	if tempDir != "" {
+		if err := os.RemoveAll(tempDir); err != nil {
+			fmt.Fprintf(&errBuf, "cleaning tempDir: %v", err)
+		}
+	}
+	if errBuf.Len() > 0 {
+		return errors.New(errBuf.String())
+	}
+	return nil
+}
diff --git a/gopls/internal/regtest/bench/workspace_symbols_test.go b/gopls/internal/regtest/bench/workspace_symbols_test.go
index 482425c..ac9ad53 100644
--- a/gopls/internal/regtest/bench/workspace_symbols_test.go
+++ b/gopls/internal/regtest/bench/workspace_symbols_test.go
@@ -15,7 +15,7 @@
 // BenchmarkWorkspaceSymbols benchmarks the time to execute a workspace symbols
 // request (controlled by the -symbol_query flag).
 func BenchmarkWorkspaceSymbols(b *testing.B) {
-	env := sharedEnv(b)
+	env := repos["tools"].sharedEnv(b)
 
 	// Make an initial symbol query to warm the cache.
 	symbols := env.Symbol(*symbolQuery)