[gopls-release-branch.0.11] gopls/internal/lsp/cache: record parse keys when they're created

When we parse a file through snapshot.ParseGo, we must record the parse
key by its URI for later invalidation. This wasn't done, resulting in a
memory leak.

Fix the leak by actually recording parse keys in the parseKeysByURI map,
which was previously always empty.

Write a test that diffs the cache before and after a change, which would
have caught this bug (and others).

Fixes golang/go#57222

Change-Id: I308812bf1030276dff08c26d359433750f44849a
Reviewed-on: https://go-review.googlesource.com/c/tools/+/456642
Reviewed-by: Alan Donovan <adonovan@google.com>
Run-TryBot: Robert Findley <rfindley@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
gopls-CI: kokoro <noreply+kokoro@google.com>
(cherry picked from commit a3eef2595adfe42f26e607e25f136b5d5388419c)
Reviewed-on: https://go-review.googlesource.com/c/tools/+/456815
diff --git a/gopls/internal/lsp/cache/parse.go b/gopls/internal/lsp/cache/parse.go
index 4f905e9..33ca98c 100644
--- a/gopls/internal/lsp/cache/parse.go
+++ b/gopls/internal/lsp/cache/parse.go
@@ -59,7 +59,7 @@
 
 	// cache miss?
 	if !hit {
-		handle, release := s.store.Promise(key, func(ctx context.Context, arg interface{}) interface{} {
+		promise, release := s.store.Promise(key, func(ctx context.Context, arg interface{}) interface{} {
 			parsed, err := parseGoImpl(ctx, arg.(*snapshot).FileSet(), fh, mode)
 			return parseGoResult{parsed, err}
 		})
@@ -70,8 +70,30 @@
 			entry = prev
 			release()
 		} else {
-			entry = handle
+			entry = promise
 			s.parsedGoFiles.Set(key, entry, func(_, _ interface{}) { release() })
+
+			// In order to correctly invalidate the key above, we must keep track of
+			// the parse key just created.
+			//
+			// TODO(rfindley): use a two-level map URI->parseKey->promise.
+			keys, _ := s.parseKeysByURI.Get(fh.URI())
+
+			// Only record the new key if it doesn't exist. This is overly cautious:
+			// we should only be setting the key if it doesn't exist. However, this
+			// logic will be replaced soon, and erring on the side of caution seemed
+			// wise.
+			foundKey := false
+			for _, existing := range keys {
+				if existing == key {
+					foundKey = true
+					break
+				}
+			}
+			if !foundKey {
+				keys = append(keys, key)
+				s.parseKeysByURI.Set(fh.URI(), keys)
+			}
 		}
 		s.mu.Unlock()
 	}
diff --git a/gopls/internal/regtest/misc/leak_test.go b/gopls/internal/regtest/misc/leak_test.go
new file mode 100644
index 0000000..71c8b13
--- /dev/null
+++ b/gopls/internal/regtest/misc/leak_test.go
@@ -0,0 +1,82 @@
+// Copyright 2022 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 misc
+
+import (
+	"context"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"golang.org/x/tools/gopls/internal/hooks"
+	"golang.org/x/tools/gopls/internal/lsp/cache"
+	"golang.org/x/tools/gopls/internal/lsp/debug"
+	"golang.org/x/tools/gopls/internal/lsp/fake"
+	"golang.org/x/tools/gopls/internal/lsp/lsprpc"
+	. "golang.org/x/tools/gopls/internal/lsp/regtest"
+	"golang.org/x/tools/internal/jsonrpc2"
+	"golang.org/x/tools/internal/jsonrpc2/servertest"
+)
+
+// Test for golang/go#57222.
+func TestCacheLeak(t *testing.T) {
+	const files = `-- a.go --
+package a
+
+func _() {
+	println("1")
+}
+`
+	c := cache.New(nil, nil)
+	env := setupEnv(t, files, c)
+	env.Await(InitialWorkspaceLoad)
+	env.OpenFile("a.go")
+
+	// Make a couple edits to stabilize cache state.
+	//
+	// For some reason, after only one edit we're left with two parsed files
+	// (perhaps because something had to ParseHeader). If this test proves flaky,
+	// we'll need to investigate exactly what is causing various parse modes to
+	// be present (or rewrite the test to be more tolerant, for example make ~100
+	// modifications and assert that we're within a few of where we're started).
+	env.RegexpReplace("a.go", "1", "2")
+	env.RegexpReplace("a.go", "2", "3")
+	env.AfterChange()
+
+	// Capture cache state, make an arbitrary change, and wait for gopls to do
+	// its work. Afterward, we should have the exact same number of parsed
+	before := c.MemStats()
+	env.RegexpReplace("a.go", "3", "4")
+	env.AfterChange()
+	after := c.MemStats()
+
+	if diff := cmp.Diff(before, after); diff != "" {
+		t.Errorf("store objects differ after change (-before +after)\n%s", diff)
+	}
+}
+
+// setupEnv creates a new sandbox environment for editing the txtar encoded
+// content of files. It uses a new gopls instance backed by the Cache c.
+func setupEnv(t *testing.T, files string, c *cache.Cache) *Env {
+	ctx := debug.WithInstance(context.Background(), "", "off")
+	server := lsprpc.NewStreamServer(c, false, hooks.Options)
+	ts := servertest.NewPipeServer(server, jsonrpc2.NewRawStream)
+	s, err := fake.NewSandbox(&fake.SandboxConfig{
+		Files: fake.UnpackTxt(files),
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	a := NewAwaiter(s.Workdir)
+	e, err := fake.NewEditor(s, fake.EditorConfig{}).Connect(ctx, ts, a.Hooks())
+
+	return &Env{
+		T:       t,
+		Ctx:     ctx,
+		Editor:  e,
+		Sandbox: s,
+		Awaiter: a,
+	}
+}