From bf8a0b2e425e55c5a83df14a59e102900f001170 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sat, 28 Mar 2026 10:50:37 -0700 Subject: [PATCH 1/3] Don't send progress reports for very short spans --- cmd/tsgo/lsp.go | 2 + internal/lsp/progress.go | 154 +++++++++---- internal/lsp/progress_test.go | 415 ++++++++++++++++++++++++++++++++++ internal/lsp/server.go | 5 +- 4 files changed, 536 insertions(+), 40 deletions(-) create mode 100644 internal/lsp/progress_test.go diff --git a/cmd/tsgo/lsp.go b/cmd/tsgo/lsp.go index b775d122dcc..25c75e9a867 100644 --- a/cmd/tsgo/lsp.go +++ b/cmd/tsgo/lsp.go @@ -8,6 +8,7 @@ import ( "os/exec" "os/signal" "syscall" + "time" "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/core" @@ -56,6 +57,7 @@ func runLSP(args []string) int { cmd.Dir = cwd return cmd.Output() }, + ProgressDelay: 250 * time.Millisecond, }) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/internal/lsp/progress.go b/internal/lsp/progress.go index 799f28e658a..04f31807c7a 100644 --- a/internal/lsp/progress.go +++ b/internal/lsp/progress.go @@ -2,6 +2,7 @@ package lsp import ( "fmt" + "time" "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" @@ -15,22 +16,72 @@ type progressEvent struct { finish bool } +// progressReporter abstracts the LSP transport operations needed by +// projectLoadingProgress so the progress logic can be tested without a +// full Server instance. +type progressReporter interface { + // done returns a channel that is closed when the server is shutting down. + done() <-chan struct{} + // localize converts a diagnostic message to a display string. + localize(msg *diagnostics.Message, args ...any) string + // createWorkDoneProgress asks the client to create a progress token. + createWorkDoneProgress(token string) + // sendProgress sends a $/progress notification. + sendProgress(token string, value lsproto.WorkDoneProgressBeginOrReportOrEnd) +} + +// serverProgressReporter adapts *Server to the progressReporter interface. +type serverProgressReporter struct { + server *Server +} + +func (r *serverProgressReporter) done() <-chan struct{} { + return r.server.backgroundCtx.Done() +} + +func (r *serverProgressReporter) localize(msg *diagnostics.Message, args ...any) string { + return msg.Localize(r.server.locale, args...) +} + +func (r *serverProgressReporter) createWorkDoneProgress(token string) { + _, _ = sendClientRequest(r.server.backgroundCtx, r.server, lsproto.WindowWorkDoneProgressCreateInfo, &lsproto.WorkDoneProgressCreateParams{ + Token: lsproto.IntegerOrString{String: &token}, + }) +} + +func (r *serverProgressReporter) sendProgress(token string, value lsproto.WorkDoneProgressBeginOrReportOrEnd) { + _ = sendNotification(r.server, lsproto.ProgressInfo, &lsproto.ProgressParams{ + Token: lsproto.IntegerOrString{String: &token}, + Value: value, + }) +} + // projectLoadingProgress manages LSP WorkDoneProgress indicators for // long-running operations. A single persistent goroutine processes // start/finish events, maintains a ref-counted map of active operations, // and sends progress messages in order. // +// To avoid flickering on fast operations, the indicator is not shown +// until progressDelay has elapsed since the first start event. If all +// operations complete before then, no progress UI is displayed. +// // start/finish may block if the internal buffer (64 events) is full, // but will bail out if the server's background context is cancelled. type projectLoadingProgress struct { - server *Server - ch chan progressEvent + reporter progressReporter + ch chan progressEvent + delay time.Duration // time to wait before showing progress UI +} + +func newProjectLoadingProgress(server *Server, delay time.Duration) *projectLoadingProgress { + return newProjectLoadingProgressFromReporter(&serverProgressReporter{server: server}, delay) } -func newProjectLoadingProgress(server *Server) *projectLoadingProgress { +func newProjectLoadingProgressFromReporter(reporter progressReporter, delay time.Duration) *projectLoadingProgress { p := &projectLoadingProgress{ - server: server, - ch: make(chan progressEvent, 64), + reporter: reporter, + ch: make(chan progressEvent, 64), + delay: delay, } go p.run() return p @@ -40,7 +91,7 @@ func (p *projectLoadingProgress) start(message *diagnostics.Message, args ...any select { case p.ch <- progressEvent{message: message, args: args}: // Sent successfully. - case <-p.server.backgroundCtx.Done(): + case <-p.reporter.done(): // Server shutting down; drop the event. } } @@ -49,7 +100,7 @@ func (p *projectLoadingProgress) finish(message *diagnostics.Message, args ...an select { case p.ch <- progressEvent{message: message, args: args, finish: true}: // Sent successfully. - case <-p.server.backgroundCtx.Done(): + case <-p.reporter.done(): // Server shutting down; drop the event. } } @@ -62,13 +113,28 @@ func (p *projectLoadingProgress) run() { token string // current token; empty if no progress active tokenID int begun bool // whether "begin" has been sent for the current token - title = diagnostics.Loading.Localize(p.server.locale) + title = p.reporter.localize(diagnostics.Loading) ) + var delay *time.Timer + delayC := func() <-chan time.Time { + if delay == nil { + return nil + } + return delay.C + } + stopDelay := func() { + if delay != nil { + delay.Stop() + delay = nil + } + } + delayFired := false // true after the delay timer fires + for { select { case ev := <-p.ch: - text := ev.message.Localize(p.server.locale, ev.args...) + text := p.reporter.localize(ev.message, ev.args...) if !ev.finish { count := loading.GetOrZero(text) loading.Set(text, count+1) @@ -76,24 +142,11 @@ func (p *projectLoadingProgress) run() { tokenID++ token = fmt.Sprintf("tsgo-loading-%d", tokenID) begun = false - _, _ = sendClientRequest(p.server.backgroundCtx, p.server, lsproto.WindowWorkDoneProgressCreateInfo, &lsproto.WorkDoneProgressCreateParams{ - Token: lsproto.IntegerOrString{String: &token}, - }) + delayFired = false + delay = time.NewTimer(p.delay) } - if !begun { - begun = true - p.sendProgress(token, lsproto.WorkDoneProgressBeginOrReportOrEnd{ - Begin: &lsproto.WorkDoneProgressBegin{ - Title: title, - Message: &text, - }, - }) - } else { - p.sendProgress(token, lsproto.WorkDoneProgressBeginOrReportOrEnd{ - Report: &lsproto.WorkDoneProgressReport{ - Message: &text, - }, - }) + if delayFired { + begun = p.beginOrReport(token, title, text, begun) } } else { count := loading.GetOrZero(text) @@ -106,13 +159,16 @@ func (p *projectLoadingProgress) run() { continue } if loading.Size() == 0 { - p.sendProgress(token, lsproto.WorkDoneProgressBeginOrReportOrEnd{ - End: &lsproto.WorkDoneProgressEnd{}, - }) + if begun { + p.reporter.sendProgress(token, lsproto.WorkDoneProgressBeginOrReportOrEnd{ + End: &lsproto.WorkDoneProgressEnd{}, + }) + } + stopDelay() token = "" - } else { + } else if delayFired { first := core.FirstOrNilSeq(loading.Keys()) - p.sendProgress(token, lsproto.WorkDoneProgressBeginOrReportOrEnd{ + p.reporter.sendProgress(token, lsproto.WorkDoneProgressBeginOrReportOrEnd{ Report: &lsproto.WorkDoneProgressReport{ Message: &first, }, @@ -120,17 +176,37 @@ func (p *projectLoadingProgress) run() { } } - case <-p.server.backgroundCtx.Done(): + case <-delayC(): + delayFired = true + if token != "" && loading.Size() > 0 { + p.reporter.createWorkDoneProgress(token) + first := core.FirstOrNilSeq(loading.Keys()) + begun = p.beginOrReport(token, title, first, begun) + } + + case <-p.reporter.done(): + stopDelay() return } } } -// sendProgress sends a $/progress notification with a snapshot of the token -// string, so deferred serialization in the write loop won't see a mutated value. -func (p *projectLoadingProgress) sendProgress(token string, value lsproto.WorkDoneProgressBeginOrReportOrEnd) { - _ = sendNotification(p.server, lsproto.ProgressInfo, &lsproto.ProgressParams{ - Token: lsproto.IntegerOrString{String: &token}, - Value: value, - }) +// beginOrReport sends WorkDoneProgressBegin if not yet begun, otherwise +// sends WorkDoneProgressReport. Returns true to indicate begun state. +func (p *projectLoadingProgress) beginOrReport(token, title, text string, begun bool) bool { + if !begun { + p.reporter.sendProgress(token, lsproto.WorkDoneProgressBeginOrReportOrEnd{ + Begin: &lsproto.WorkDoneProgressBegin{ + Title: title, + Message: &text, + }, + }) + } else { + p.reporter.sendProgress(token, lsproto.WorkDoneProgressBeginOrReportOrEnd{ + Report: &lsproto.WorkDoneProgressReport{ + Message: &text, + }, + }) + } + return true } diff --git a/internal/lsp/progress_test.go b/internal/lsp/progress_test.go new file mode 100644 index 00000000000..af899f374b0 --- /dev/null +++ b/internal/lsp/progress_test.go @@ -0,0 +1,415 @@ +package lsp + +import ( + "context" + "sync" + "testing" + "testing/synctest" + "time" + + "github.com/microsoft/typescript-go/internal/diagnostics" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" +) + +type progressCall struct { + method string // "create", "begin", "report", "end" + token string + title string // begin only + msg string // begin/report only +} + +type fakeProgressReporter struct { + mu sync.Mutex + calls []progressCall + ctx context.Context +} + +func (f *fakeProgressReporter) done() <-chan struct{} { + return f.ctx.Done() +} + +func (f *fakeProgressReporter) localize(msg *diagnostics.Message, _ ...any) string { + return msg.String() +} + +func (f *fakeProgressReporter) createWorkDoneProgress(token string) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, progressCall{method: "create", token: token}) +} + +func (f *fakeProgressReporter) sendProgress(token string, value lsproto.WorkDoneProgressBeginOrReportOrEnd) { + f.mu.Lock() + defer f.mu.Unlock() + switch { + case value.Begin != nil: + msg := "" + if value.Begin.Message != nil { + msg = *value.Begin.Message + } + f.calls = append(f.calls, progressCall{method: "begin", token: token, title: value.Begin.Title, msg: msg}) + case value.Report != nil: + msg := "" + if value.Report.Message != nil { + msg = *value.Report.Message + } + f.calls = append(f.calls, progressCall{method: "report", token: token, msg: msg}) + case value.End != nil: + f.calls = append(f.calls, progressCall{method: "end", token: token}) + } +} + +func (f *fakeProgressReporter) getCalls() []progressCall { + f.mu.Lock() + defer f.mu.Unlock() + return append([]progressCall(nil), f.calls...) +} + +func TestProgress(t *testing.T) { + t.Parallel() + + t.Run("StartFinishBeforeDelay", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + reporter := &fakeProgressReporter{ctx: ctx} + p := newProjectLoadingProgressFromReporter(reporter, 500*time.Millisecond) + + p.start(diagnostics.Project_0, "myProject") + synctest.Wait() + + // Finish before the delay fires — no UI should appear. + p.finish(diagnostics.Project_0, "myProject") + synctest.Wait() + + // Advance time past the delay to ensure no progress is sent. + time.Sleep(600 * time.Millisecond) + synctest.Wait() + + calls := reporter.getCalls() + if len(calls) != 0 { + t.Fatalf("expected no progress calls for fast operation, got %v", calls) + } + + cancel() + }) + }) + + t.Run("ShowsAfterDelay", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + reporter := &fakeProgressReporter{ctx: ctx} + p := newProjectLoadingProgressFromReporter(reporter, 500*time.Millisecond) + + p.start(diagnostics.Project_0, "myProject") + synctest.Wait() + + // Let the delay fire. + time.Sleep(500 * time.Millisecond) + synctest.Wait() + + calls := reporter.getCalls() + if len(calls) != 2 { + t.Fatalf("expected 2 calls (create + begin), got %d: %v", len(calls), calls) + } + if calls[0].method != "create" { + t.Fatalf("expected create, got %v", calls[0]) + } + if calls[1].method != "begin" { + t.Fatalf("expected begin, got %v", calls[1]) + } + if calls[1].title != diagnostics.Loading.String() { + t.Fatalf("expected title %q, got %q", diagnostics.Loading.String(), calls[1].title) + } + + // Finish the operation. + p.finish(diagnostics.Project_0, "myProject") + synctest.Wait() + + calls = reporter.getCalls() + last := calls[len(calls)-1] + if last.method != "end" { + t.Fatalf("expected end, got %v", last) + } + + cancel() + }) + }) + + t.Run("ReportsMultipleOperations", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + reporter := &fakeProgressReporter{ctx: ctx} + p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond) + + // Start two different operations. + p.start(diagnostics.Project_0, "projA") + p.start(diagnostics.Project_0, "projB") + synctest.Wait() + + // Let the delay fire. + time.Sleep(100 * time.Millisecond) + synctest.Wait() + + calls := reporter.getCalls() + // Should have: create, begin (with first message). + if len(calls) < 2 { + t.Fatalf("expected at least 2 calls, got %d: %v", len(calls), calls) + } + if calls[0].method != "create" { + t.Fatalf("expected create, got %v", calls[0]) + } + if calls[1].method != "begin" { + t.Fatalf("expected begin, got %v", calls[1]) + } + + // Finish one — should send a report with the remaining operation. + p.finish(diagnostics.Project_0, "projA") + synctest.Wait() + + calls = reporter.getCalls() + found := false + for _, c := range calls { + if c.method == "report" { + found = true + break + } + } + if !found { + t.Fatalf("expected a report after partial finish, got %v", calls) + } + + // Finish the second — should send end. + p.finish(diagnostics.Project_0, "projB") + synctest.Wait() + + calls = reporter.getCalls() + last := calls[len(calls)-1] + if last.method != "end" { + t.Fatalf("expected end, got %v", last) + } + + cancel() + }) + }) + + t.Run("RefCounting", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + reporter := &fakeProgressReporter{ctx: ctx} + p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond) + + // Start the same operation twice (ref count = 2). + p.start(diagnostics.Project_0, "proj") + p.start(diagnostics.Project_0, "proj") + synctest.Wait() + + time.Sleep(100 * time.Millisecond) + synctest.Wait() + + // Finish once (ref count = 1) — should NOT end. + p.finish(diagnostics.Project_0, "proj") + synctest.Wait() + + calls := reporter.getCalls() + for _, c := range calls { + if c.method == "end" { + t.Fatalf("unexpected end with ref count > 0: %v", calls) + } + } + + // Finish again (ref count = 0) — should end. + p.finish(diagnostics.Project_0, "proj") + synctest.Wait() + + calls = reporter.getCalls() + last := calls[len(calls)-1] + if last.method != "end" { + t.Fatalf("expected end when ref count reaches 0, got %v", last) + } + + cancel() + }) + }) + + t.Run("NewTokenAfterEnd", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + reporter := &fakeProgressReporter{ctx: ctx} + p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond) + + // First cycle. + p.start(diagnostics.Project_0, "proj") + synctest.Wait() + time.Sleep(100 * time.Millisecond) + synctest.Wait() + + calls := reporter.getCalls() + firstToken := calls[0].token + + p.finish(diagnostics.Project_0, "proj") + synctest.Wait() + + // Second cycle — should get a new token. + p.start(diagnostics.Project_0, "proj2") + synctest.Wait() + time.Sleep(100 * time.Millisecond) + synctest.Wait() + + calls = reporter.getCalls() + var secondToken string + for _, c := range calls { + if c.method == "create" && c.token != firstToken { + secondToken = c.token + break + } + } + if secondToken == "" { + t.Fatalf("expected a new token for second cycle, got calls: %v", calls) + } + if firstToken == secondToken { + t.Fatalf("expected different tokens, both were %q", firstToken) + } + + p.finish(diagnostics.Project_0, "proj2") + synctest.Wait() + + cancel() + }) + }) + + t.Run("StartBeforeDelayThenMoreAfterDelay", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + reporter := &fakeProgressReporter{ctx: ctx} + p := newProjectLoadingProgressFromReporter(reporter, 200*time.Millisecond) + + // Start before delay. + p.start(diagnostics.Project_0, "projA") + synctest.Wait() + + // Let delay fire. + time.Sleep(200 * time.Millisecond) + synctest.Wait() + + calls := reporter.getCalls() + if len(calls) < 2 { + t.Fatalf("expected create + begin after delay, got %v", calls) + } + + // Start another operation after delay — should send a report immediately. + p.start(diagnostics.Project_0, "projB") + synctest.Wait() + + calls = reporter.getCalls() + last := calls[len(calls)-1] + if last.method != "report" { + t.Fatalf("expected report for new start after delay, got %v", last) + } + + // Clean up. + p.finish(diagnostics.Project_0, "projA") + p.finish(diagnostics.Project_0, "projB") + synctest.Wait() + + cancel() + }) + }) + + t.Run("FinishWithNoActiveToken", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + reporter := &fakeProgressReporter{ctx: ctx} + p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond) + + // Finish without any prior start — should be a no-op. + p.finish(diagnostics.Project_0, "proj") + synctest.Wait() + + calls := reporter.getCalls() + if len(calls) != 0 { + t.Fatalf("expected no calls for orphan finish, got %v", calls) + } + + cancel() + }) + }) + + t.Run("ShutdownDuringStartAndFinish", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reporter := &fakeProgressReporter{ctx: ctx} + p := newProjectLoadingProgressFromReporter(reporter, 100*time.Millisecond) + + // Cancel context so the run goroutine exits. + cancel() + synctest.Wait() + + // Fill the channel buffer so start/finish block on send. + for range 64 { + p.ch <- progressEvent{message: diagnostics.Project_0, args: []any{"fill"}} + } + + // These should return immediately via the done() path + // since the channel is full and the context is cancelled. + p.start(diagnostics.Project_0, "proj") + p.finish(diagnostics.Project_0, "proj") + }) + }) + + t.Run("ShutdownWithActiveTimer", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reporter := &fakeProgressReporter{ctx: ctx} + p := newProjectLoadingProgressFromReporter(reporter, 500*time.Millisecond) + + // Start an operation so the delay timer is created. + p.start(diagnostics.Project_0, "proj") + synctest.Wait() + + // Shutdown while the delay timer is still pending. + cancel() + synctest.Wait() + }) + }) + + t.Run("FinishBeforeDelayNoBegun", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + reporter := &fakeProgressReporter{ctx: ctx} + p := newProjectLoadingProgressFromReporter(reporter, 500*time.Millisecond) + + // Start, then finish before delay — begun is false, so no end is sent. + p.start(diagnostics.Project_0, "proj") + synctest.Wait() + p.finish(diagnostics.Project_0, "proj") + synctest.Wait() + + calls := reporter.getCalls() + for _, c := range calls { + if c.method == "end" { + t.Fatalf("unexpected end when begun=false: %v", calls) + } + } + + cancel() + }) + }) +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index f631de4609c..fbd572d9661 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -44,6 +44,7 @@ type ServerOptions struct { TypingsLocation string ParseCache *project.ParseCache NpmInstall func(cwd string, args []string) ([]byte, error) + ProgressDelay time.Duration // delay before showing progress UI; 0 means no delay } func NewServer(opts *ServerOptions) *Server { @@ -66,6 +67,7 @@ func NewServer(opts *ServerOptions) *Server { parseCache: opts.ParseCache, npmInstall: opts.NpmInstall, initComplete: make(chan struct{}), + progressDelay: opts.ProgressDelay, } s.logger = newLogger(s) @@ -189,6 +191,7 @@ type Server struct { cpuProfiler pprof.CPUProfiler + progressDelay time.Duration projectProgress *projectLoadingProgress } @@ -935,7 +938,7 @@ func (s *Server) handleInitialize(ctx context.Context, params *lsproto.Initializ s.initializeParams = params s.clientCapabilities = lsproto.ResolveClientCapabilities(params.Capabilities) if s.clientCapabilities.Window.WorkDoneProgress { - s.projectProgress = newProjectLoadingProgress(s) + s.projectProgress = newProjectLoadingProgress(s, s.progressDelay) } capabilitiesJSON, err := json.MarshalIndent(&s.clientCapabilities, "", "\t") From 07a6051369ac841a204774a96c032b87cb33653e Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:44:59 -0700 Subject: [PATCH 2/3] PR feedback --- internal/lsp/progress.go | 9 +++++-- internal/lsp/progress_test.go | 45 +++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/internal/lsp/progress.go b/internal/lsp/progress.go index 04f31807c7a..58c1b5ccb8e 100644 --- a/internal/lsp/progress.go +++ b/internal/lsp/progress.go @@ -142,8 +142,13 @@ func (p *projectLoadingProgress) run() { tokenID++ token = fmt.Sprintf("tsgo-loading-%d", tokenID) begun = false - delayFired = false - delay = time.NewTimer(p.delay) + if p.delay <= 0 { + delayFired = true + p.reporter.createWorkDoneProgress(token) + } else { + delayFired = false + delay = time.NewTimer(p.delay) + } } if delayFired { begun = p.beginOrReport(token, title, text, begun) diff --git a/internal/lsp/progress_test.go b/internal/lsp/progress_test.go index af899f374b0..e040fe5ebf6 100644 --- a/internal/lsp/progress_test.go +++ b/internal/lsp/progress_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/microsoft/typescript-go/internal/diagnostics" + "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/lsp/lsproto" ) @@ -28,8 +29,8 @@ func (f *fakeProgressReporter) done() <-chan struct{} { return f.ctx.Done() } -func (f *fakeProgressReporter) localize(msg *diagnostics.Message, _ ...any) string { - return msg.String() +func (f *fakeProgressReporter) localize(msg *diagnostics.Message, args ...any) string { + return msg.Localize(locale.Default, args...) } func (f *fakeProgressReporter) createWorkDoneProgress(token string) { @@ -388,6 +389,46 @@ func TestProgress(t *testing.T) { }) }) + t.Run("ZeroDelay", func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + reporter := &fakeProgressReporter{ctx: ctx} + p := newProjectLoadingProgressFromReporter(reporter, 0) + + // With zero delay, progress should begin immediately. + p.start(diagnostics.Project_0, "proj") + synctest.Wait() + + calls := reporter.getCalls() + if len(calls) != 2 { + t.Fatalf("expected 2 calls (create + begin), got %d: %v", len(calls), calls) + } + if calls[0].method != "create" { + t.Fatalf("expected create, got %v", calls[0]) + } + if calls[1].method != "begin" { + t.Fatalf("expected begin, got %v", calls[1]) + } + if calls[1].msg != "Project 'proj'" { + t.Fatalf("expected message %q, got %q", "Project 'proj'", calls[1].msg) + } + + // Start+finish should still produce begin and end. + p.finish(diagnostics.Project_0, "proj") + synctest.Wait() + + calls = reporter.getCalls() + last := calls[len(calls)-1] + if last.method != "end" { + t.Fatalf("expected end, got %v", last) + } + + cancel() + }) + }) + t.Run("FinishBeforeDelayNoBegun", func(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { From 2b457deeb443a1993cc0d9ce6d9ef6da0cf45206 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:14:42 -0700 Subject: [PATCH 3/3] PR feedback --- internal/lsp/progress.go | 8 ++++---- internal/lsp/progress_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/lsp/progress.go b/internal/lsp/progress.go index 58c1b5ccb8e..1198849b3b1 100644 --- a/internal/lsp/progress.go +++ b/internal/lsp/progress.go @@ -113,7 +113,6 @@ func (p *projectLoadingProgress) run() { token string // current token; empty if no progress active tokenID int begun bool // whether "begin" has been sent for the current token - title = p.reporter.localize(diagnostics.Loading) ) var delay *time.Timer @@ -151,7 +150,7 @@ func (p *projectLoadingProgress) run() { } } if delayFired { - begun = p.beginOrReport(token, title, text, begun) + begun = p.beginOrReport(token, text, begun) } } else { count := loading.GetOrZero(text) @@ -186,7 +185,7 @@ func (p *projectLoadingProgress) run() { if token != "" && loading.Size() > 0 { p.reporter.createWorkDoneProgress(token) first := core.FirstOrNilSeq(loading.Keys()) - begun = p.beginOrReport(token, title, first, begun) + begun = p.beginOrReport(token, first, begun) } case <-p.reporter.done(): @@ -198,8 +197,9 @@ func (p *projectLoadingProgress) run() { // beginOrReport sends WorkDoneProgressBegin if not yet begun, otherwise // sends WorkDoneProgressReport. Returns true to indicate begun state. -func (p *projectLoadingProgress) beginOrReport(token, title, text string, begun bool) bool { +func (p *projectLoadingProgress) beginOrReport(token, text string, begun bool) bool { if !begun { + title := p.reporter.localize(diagnostics.Loading) p.reporter.sendProgress(token, lsproto.WorkDoneProgressBeginOrReportOrEnd{ Begin: &lsproto.WorkDoneProgressBegin{ Title: title, diff --git a/internal/lsp/progress_test.go b/internal/lsp/progress_test.go index e040fe5ebf6..59d01a8f770 100644 --- a/internal/lsp/progress_test.go +++ b/internal/lsp/progress_test.go @@ -361,7 +361,7 @@ func TestProgress(t *testing.T) { synctest.Wait() // Fill the channel buffer so start/finish block on send. - for range 64 { + for range cap(p.ch) { p.ch <- progressEvent{message: diagnostics.Project_0, args: []any{"fill"}} }