Skip to content

Commit ebb9ee3

Browse files
committed
gopls/internal/lsp/cache: UX improvements for analysis
When analysis takes a long time, it can be a poor experience for our users. Address this in a number of ways: - Limit parallelism of analysis, so that it doesn't consume all available memory. - Add progress reporting if analysis takes longer than 5s, for better visibility and to allow cancellation. In case this is annoying to users, add a setting to disable this feature. For golang/go#61178 Change-Id: I15e05f39c606ff7a3fecee672918ee3c4a640fbc Reviewed-on: https://go-review.googlesource.com/c/tools/+/511215 gopls-CI: kokoro <[email protected]> Run-TryBot: Robert Findley <[email protected]> TryBot-Result: Gopher Robot <[email protected]> Reviewed-by: Alan Donovan <[email protected]>
1 parent 3e82a5b commit ebb9ee3

File tree

12 files changed

+220
-22
lines changed

12 files changed

+220
-22
lines changed

gopls/doc/settings.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,20 @@ This option must be set to a valid duration string, for example `"250ms"`.
344344

345345
Default: `"1s"`.
346346

347+
##### **analysisProgressReporting** *bool*
348+
349+
analysisProgressReporting controls whether gopls sends progress
350+
notifications when construction of its index of analysis facts is taking a
351+
long time. Cancelling these notifications will cancel the indexing task,
352+
though it will restart after the next change in the workspace.
353+
354+
When a package is opened for the first time and heavyweight analyses such as
355+
staticcheck are enabled, it can take a while to construct the index of
356+
analysis facts for all its dependencies. The index is cached in the
357+
filesystem, so subsequent analysis should be faster.
358+
359+
Default: `true`.
360+
347361
#### Documentation
348362

349363
##### **hoverKind** *enum*

gopls/internal/lsp/cache/analysis.go

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"log"
2121
urlpkg "net/url"
2222
"reflect"
23+
"runtime"
2324
"runtime/debug"
2425
"sort"
2526
"strings"
@@ -32,6 +33,7 @@ import (
3233
"golang.org/x/tools/gopls/internal/bug"
3334
"golang.org/x/tools/gopls/internal/lsp/filecache"
3435
"golang.org/x/tools/gopls/internal/lsp/frob"
36+
"golang.org/x/tools/gopls/internal/lsp/progress"
3537
"golang.org/x/tools/gopls/internal/lsp/protocol"
3638
"golang.org/x/tools/gopls/internal/lsp/source"
3739
"golang.org/x/tools/internal/event"
@@ -154,14 +156,23 @@ import (
154156
// View() *View // for Options
155157
// - share cache.{goVersionRx,parseGoImpl}
156158

159+
// AnalysisProgressTitle is the title of the progress report for ongoing
160+
// analysis. It is sought by regression tests for the progress reporting
161+
// feature.
162+
const AnalysisProgressTitle = "Analyzing Dependencies"
163+
157164
// Analyze applies a set of analyzers to the package denoted by id,
158165
// and returns their diagnostics for that package.
159166
//
160167
// The analyzers list must be duplicate free; order does not matter.
161168
//
169+
// Notifications of progress may be sent to the optional reporter.
170+
//
162171
// Precondition: all analyzers within the process have distinct names.
163172
// (The names are relied on by the serialization logic.)
164-
func (snapshot *snapshot) Analyze(ctx context.Context, pkgs map[PackageID]unit, analyzers []*source.Analyzer) ([]*source.Diagnostic, error) {
173+
func (snapshot *snapshot) Analyze(ctx context.Context, pkgs map[PackageID]unit, analyzers []*source.Analyzer, reporter *progress.Tracker) ([]*source.Diagnostic, error) {
174+
start := time.Now() // for progress reporting
175+
165176
var tagStr string // sorted comma-separated list of PackageIDs
166177
{
167178
// TODO(adonovan): replace with a generic map[S]any -> string
@@ -296,21 +307,84 @@ func (snapshot *snapshot) Analyze(ctx context.Context, pkgs map[PackageID]unit,
296307

297308
// Now that we have read all files,
298309
// we no longer need the snapshot.
310+
// (but options are needed for progress reporting)
311+
options := snapshot.view.Options()
299312
snapshot = nil
300313

314+
// Progress reporting. If supported, gopls reports progress on analysis
315+
// passes that are taking a long time.
316+
maybeReport := func(completed int64) {}
317+
318+
// Enable progress reporting if enabled by the user
319+
// and we have a capable reporter.
320+
if reporter != nil && reporter.SupportsWorkDoneProgress() && options.AnalysisProgressReporting {
321+
var reportAfter = options.ReportAnalysisProgressAfter // tests may set this to 0
322+
const reportEvery = 1 * time.Second
323+
324+
ctx, cancel := context.WithCancel(ctx)
325+
defer cancel()
326+
327+
var (
328+
reportMu sync.Mutex
329+
lastReport time.Time
330+
wd *progress.WorkDone
331+
)
332+
defer func() {
333+
reportMu.Lock()
334+
defer reportMu.Unlock()
335+
336+
if wd != nil {
337+
wd.End(ctx, "Done.") // ensure that the progress report exits
338+
}
339+
}()
340+
maybeReport = func(completed int64) {
341+
now := time.Now()
342+
if now.Sub(start) < reportAfter {
343+
return
344+
}
345+
346+
reportMu.Lock()
347+
defer reportMu.Unlock()
348+
349+
if wd == nil {
350+
wd = reporter.Start(ctx, AnalysisProgressTitle, "", nil, cancel)
351+
}
352+
353+
if now.Sub(lastReport) > reportEvery {
354+
lastReport = now
355+
// Trailing space is intentional: some LSP clients strip newlines.
356+
msg := fmt.Sprintf(`Constructing index of analysis facts... (%d/%d packages).
357+
(Set "analysisProgressReporting" to false to disable notifications.)`,
358+
completed, len(nodes))
359+
pct := 100 * float64(completed) / float64(len(nodes))
360+
wd.Report(ctx, msg, pct)
361+
}
362+
}
363+
}
364+
301365
// Execute phase: run leaves first, adding
302366
// new nodes to the queue as they become leaves.
303367
var g errgroup.Group
304-
// Avoid g.SetLimit here: it makes g.Go stop accepting work,
305-
// which prevents workers from enqeuing, and thus finishing,
306-
// and thus allowing the group to make progress: deadlock.
368+
369+
// Analysis is CPU-bound.
370+
//
371+
// Note: avoid g.SetLimit here: it makes g.Go stop accepting work, which
372+
// prevents workers from enqeuing, and thus finishing, and thus allowing the
373+
// group to make progress: deadlock.
374+
limiter := make(chan unit, runtime.GOMAXPROCS(0))
375+
var completed int64
376+
307377
var enqueue func(*analysisNode)
308378
enqueue = func(an *analysisNode) {
309379
g.Go(func() error {
380+
limiter <- unit{}
381+
defer func() { <-limiter }()
382+
310383
summary, err := an.runCached(ctx)
311384
if err != nil {
312385
return err // cancelled, or failed to produce a package
313386
}
387+
maybeReport(atomic.AddInt64(&completed, 1))
314388
an.summary = summary
315389

316390
// Notify each waiting predecessor,

gopls/internal/lsp/code_action.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ func (s *Server) codeAction(ctx context.Context, params *protocol.CodeActionPara
203203
if err != nil {
204204
return nil, err
205205
}
206-
analysisDiags, err := source.Analyze(ctx, snapshot, map[source.PackageID]unit{pkg.Metadata().ID: {}}, true)
206+
analysisDiags, err := source.Analyze(ctx, snapshot, map[source.PackageID]unit{pkg.Metadata().ID: {}}, true, s.progress)
207207
if err != nil {
208208
return nil, err
209209
}

gopls/internal/lsp/diagnostics.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ func (s *Server) diagnosePkgs(ctx context.Context, snapshot source.Snapshot, toD
448448
wg.Add(1)
449449
go func() {
450450
defer wg.Done()
451-
diags, err := source.Analyze(ctx, snapshot, toAnalyze, false)
451+
diags, err := source.Analyze(ctx, snapshot, toAnalyze, false, s.progress)
452452
if err != nil {
453453
var tagStr string // sorted comma-separated list of package IDs
454454
{

gopls/internal/lsp/progress/progress.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,22 @@ func NewTracker(client protocol.Client) *Tracker {
3333
}
3434
}
3535

36+
// SetSupportsWorkDoneProgress sets whether the client supports work done
37+
// progress reporting. It must be set before using the tracker.
38+
//
39+
// TODO(rfindley): fix this broken initialization pattern.
40+
// Also: do we actually need the fall-back progress behavior using ShowMessage?
41+
// Surely ShowMessage notifications are too noisy to be worthwhile.
3642
func (tracker *Tracker) SetSupportsWorkDoneProgress(b bool) {
3743
tracker.supportsWorkDoneProgress = b
3844
}
3945

46+
// SupportsWorkDoneProgress reports whether the tracker supports work done
47+
// progress reporting.
48+
func (tracker *Tracker) SupportsWorkDoneProgress() bool {
49+
return tracker.supportsWorkDoneProgress
50+
}
51+
4052
// Start notifies the client of work being done on the server. It uses either
4153
// ShowMessage RPCs or $/progress messages, depending on the capabilities of
4254
// the client. The returned WorkDone handle may be used to report incremental

gopls/internal/lsp/regtest/expectation.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,26 @@ func describeExpectations(expectations ...Expectation) string {
105105
return strings.Join(descriptions, "\n")
106106
}
107107

108+
// Not inverts the sense of an expectation: a met expectation is unmet, and an
109+
// unmet expectation is met.
110+
func Not(e Expectation) Expectation {
111+
check := func(s State) Verdict {
112+
switch v := e.Check(s); v {
113+
case Met:
114+
return Unmet
115+
case Unmet, Unmeetable:
116+
return Met
117+
default:
118+
panic(fmt.Sprintf("unexpected verdict %v", v))
119+
}
120+
}
121+
description := describeExpectations(e)
122+
return Expectation{
123+
Check: check,
124+
Description: fmt.Sprintf("not: %s", description),
125+
}
126+
}
127+
108128
// AnyOf returns an expectation that is satisfied when any of the given
109129
// expectations is met.
110130
func AnyOf(anyOf ...Expectation) Expectation {

gopls/internal/lsp/source/api_json.go

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gopls/internal/lsp/source/diagnostics.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"encoding/json"
1010

1111
"golang.org/x/tools/gopls/internal/bug"
12+
"golang.org/x/tools/gopls/internal/lsp/progress"
1213
"golang.org/x/tools/gopls/internal/lsp/protocol"
1314
"golang.org/x/tools/gopls/internal/span"
1415
)
@@ -21,7 +22,10 @@ type SuggestedFix struct {
2122
}
2223

2324
// Analyze reports go/analysis-framework diagnostics in the specified package.
24-
func Analyze(ctx context.Context, snapshot Snapshot, pkgIDs map[PackageID]unit, includeConvenience bool) (map[span.URI][]*Diagnostic, error) {
25+
//
26+
// If the provided tracker is non-nil, it may be used to provide notifications
27+
// of the ongoing analysis pass.
28+
func Analyze(ctx context.Context, snapshot Snapshot, pkgIDs map[PackageID]unit, includeConvenience bool, tracker *progress.Tracker) (map[span.URI][]*Diagnostic, error) {
2529
// Exit early if the context has been canceled. This also protects us
2630
// from a race on Options, see golang/go#36699.
2731
if ctx.Err() != nil {
@@ -45,7 +49,7 @@ func Analyze(ctx context.Context, snapshot Snapshot, pkgIDs map[PackageID]unit,
4549
}
4650
}
4751

48-
analysisDiagnostics, err := snapshot.Analyze(ctx, pkgIDs, analyzers)
52+
analysisDiagnostics, err := snapshot.Analyze(ctx, pkgIDs, analyzers, tracker)
4953
if err != nil {
5054
return nil, err
5155
}
@@ -81,7 +85,7 @@ func FileDiagnostics(ctx context.Context, snapshot Snapshot, uri span.URI) (File
8185
if err != nil {
8286
return nil, nil, err
8387
}
84-
adiags, err := Analyze(ctx, snapshot, map[PackageID]unit{pkg.Metadata().ID: {}}, false)
88+
adiags, err := Analyze(ctx, snapshot, map[PackageID]unit{pkg.Metadata().ID: {}}, false, nil /* progress tracker */)
8589
if err != nil {
8690
return nil, nil, err
8791
}

gopls/internal/lsp/source/options.go

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -124,14 +124,15 @@ func DefaultOptions() *Options {
124124
},
125125
UIOptions: UIOptions{
126126
DiagnosticOptions: DiagnosticOptions{
127-
DiagnosticsDelay: 1 * time.Second,
128127
Annotations: map[Annotation]bool{
129128
Bounds: true,
130129
Escape: true,
131130
Inline: true,
132131
Nil: true,
133132
},
134-
Vulncheck: ModeVulncheckOff,
133+
Vulncheck: ModeVulncheckOff,
134+
DiagnosticsDelay: 1 * time.Second,
135+
AnalysisProgressReporting: true,
135136
},
136137
InlayHintOptions: InlayHintOptions{},
137138
DocumentationOptions: DocumentationOptions{
@@ -162,14 +163,15 @@ func DefaultOptions() *Options {
162163
},
163164
},
164165
InternalOptions: InternalOptions{
165-
LiteralCompletions: true,
166-
TempModfile: true,
167-
CompleteUnimported: true,
168-
CompletionDocumentation: true,
169-
DeepCompletion: true,
170-
ChattyDiagnostics: true,
171-
NewDiff: "both",
172-
SubdirWatchPatterns: SubdirWatchPatternsAuto,
166+
LiteralCompletions: true,
167+
TempModfile: true,
168+
CompleteUnimported: true,
169+
CompletionDocumentation: true,
170+
DeepCompletion: true,
171+
ChattyDiagnostics: true,
172+
NewDiff: "both",
173+
SubdirWatchPatterns: SubdirWatchPatternsAuto,
174+
ReportAnalysisProgressAfter: 5 * time.Second,
173175
},
174176
Hooks: Hooks{
175177
// TODO(adonovan): switch to new diff.Strings implementation.
@@ -428,6 +430,17 @@ type DiagnosticOptions struct {
428430
//
429431
// This option must be set to a valid duration string, for example `"250ms"`.
430432
DiagnosticsDelay time.Duration `status:"advanced"`
433+
434+
// AnalysisProgressReporting controls whether gopls sends progress
435+
// notifications when construction of its index of analysis facts is taking a
436+
// long time. Cancelling these notifications will cancel the indexing task,
437+
// though it will restart after the next change in the workspace.
438+
//
439+
// When a package is opened for the first time and heavyweight analyses such as
440+
// staticcheck are enabled, it can take a while to construct the index of
441+
// analysis facts for all its dependencies. The index is cached in the
442+
// filesystem, so subsequent analysis should be faster.
443+
AnalysisProgressReporting bool
431444
}
432445

433446
type InlayHintOptions struct {
@@ -540,7 +553,7 @@ type Hooks struct {
540553
// by the user.
541554
//
542555
// TODO(rfindley): even though these settings are not intended for
543-
// modification, we should surface them in our documentation.
556+
// modification, some of them should be surfaced in our documentation.
544557
type InternalOptions struct {
545558
// LiteralCompletions controls whether literal candidates such as
546559
// "&someStruct{}" are offered. Tests disable this flag to simplify
@@ -630,6 +643,12 @@ type InternalOptions struct {
630643
// example, if like VS Code it drops file notifications), please file an
631644
// issue.
632645
SubdirWatchPatterns SubdirWatchPatterns
646+
647+
// ReportAnalysisProgressAfter sets the duration for gopls to wait before starting
648+
// progress reporting for ongoing go/analysis passes.
649+
//
650+
// It is intended to be used for testing only.
651+
ReportAnalysisProgressAfter time.Duration
633652
}
634653

635654
type SubdirWatchPatterns string
@@ -1171,6 +1190,9 @@ func (o *Options) set(name string, value interface{}, seen map[string]struct{})
11711190
case "diagnosticsDelay":
11721191
result.setDuration(&o.DiagnosticsDelay)
11731192

1193+
case "analysisProgressReporting":
1194+
result.setBool(&o.AnalysisProgressReporting)
1195+
11741196
case "experimentalWatchedFileDelay":
11751197
result.deprecated("")
11761198

@@ -1208,6 +1230,9 @@ func (o *Options) set(name string, value interface{}, seen map[string]struct{})
12081230
o.SubdirWatchPatterns = SubdirWatchPatterns(s)
12091231
}
12101232

1233+
case "reportAnalysisProgressAfter":
1234+
result.setDuration(&o.ReportAnalysisProgressAfter)
1235+
12111236
// Replaced settings.
12121237
case "experimentalDisabledAnalyses":
12131238
result.deprecated("analyses")

gopls/internal/lsp/source/view.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"golang.org/x/tools/go/packages"
2424
"golang.org/x/tools/go/types/objectpath"
2525
"golang.org/x/tools/gopls/internal/govulncheck"
26+
"golang.org/x/tools/gopls/internal/lsp/progress"
2627
"golang.org/x/tools/gopls/internal/lsp/protocol"
2728
"golang.org/x/tools/gopls/internal/lsp/safetoken"
2829
"golang.org/x/tools/gopls/internal/lsp/source/methodsets"
@@ -93,7 +94,10 @@ type Snapshot interface {
9394
ParseGo(ctx context.Context, fh FileHandle, mode parser.Mode) (*ParsedGoFile, error)
9495

9596
// Analyze runs the specified analyzers on the given packages at this snapshot.
96-
Analyze(ctx context.Context, pkgIDs map[PackageID]unit, analyzers []*Analyzer) ([]*Diagnostic, error)
97+
//
98+
// If the provided tracker is non-nil, it may be used to report progress of
99+
// the analysis pass.
100+
Analyze(ctx context.Context, pkgIDs map[PackageID]unit, analyzers []*Analyzer, tracker *progress.Tracker) ([]*Diagnostic, error)
97101

98102
// RunGoCommandPiped runs the given `go` command, writing its output
99103
// to stdout and stderr. Verb, Args, and WorkingDir must be specified.

0 commit comments

Comments
 (0)