-
Notifications
You must be signed in to change notification settings - Fork 12
Optimize index updates #332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| package lsp | ||
|
|
||
| import ( | ||
| "sync" | ||
| "time" | ||
| ) | ||
|
|
||
| type documentDebouncer struct { | ||
| delay time.Duration | ||
| refresh func(string) | ||
|
|
||
| mu sync.Mutex | ||
| timers map[string]*time.Timer | ||
| } | ||
|
|
||
| func newDocumentDebouncer(delay time.Duration, refresh func(string)) *documentDebouncer { | ||
| return &documentDebouncer{ | ||
| delay: delay, | ||
| refresh: refresh, | ||
| timers: make(map[string]*time.Timer), | ||
| } | ||
| } | ||
|
|
||
| func (d *documentDebouncer) Schedule(uri string) { | ||
| d.mu.Lock() | ||
| defer d.mu.Unlock() | ||
|
|
||
| if timer, ok := d.timers[uri]; ok { | ||
| timer.Stop() | ||
| } | ||
|
|
||
| var timer *time.Timer | ||
|
|
||
| timer = time.AfterFunc(d.delay, func() { | ||
| d.fire(uri, timer) | ||
| }) | ||
|
|
||
| d.timers[uri] = timer | ||
| } | ||
|
|
||
| func (d *documentDebouncer) Stop() { | ||
| d.mu.Lock() | ||
| defer d.mu.Unlock() | ||
|
|
||
| for uri, timer := range d.timers { | ||
| timer.Stop() | ||
| delete(d.timers, uri) | ||
| } | ||
| } | ||
|
|
||
| func (d *documentDebouncer) fire(uri string, timer *time.Timer) { | ||
| d.mu.Lock() | ||
|
|
||
| current, ok := d.timers[uri] | ||
| if !ok || current != timer { | ||
| d.mu.Unlock() | ||
| return | ||
| } | ||
|
|
||
| delete(d.timers, uri) | ||
| d.mu.Unlock() | ||
|
|
||
| d.refresh(uri) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| package lsp | ||
|
|
||
| import ( | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/tliron/glsp" | ||
| lsp "github.com/tliron/glsp/protocol_3_16" | ||
| ) | ||
|
|
||
| func TestDocumentDebouncerCoalescesRepeatedSchedules(t *testing.T) { | ||
| events := make(chan string, 2) | ||
| debouncer := newDocumentDebouncer(20*time.Millisecond, func(uri string) { | ||
| events <- uri | ||
| }) | ||
| defer debouncer.Stop() | ||
|
|
||
| debouncer.Schedule("file:///tmp/lets.yaml") | ||
| debouncer.Schedule("file:///tmp/lets.yaml") | ||
|
|
||
| select { | ||
| case got := <-events: | ||
| if got != "file:///tmp/lets.yaml" { | ||
| t.Fatalf("refresh uri = %q, want %q", got, "file:///tmp/lets.yaml") | ||
| } | ||
| case <-time.After(200 * time.Millisecond): | ||
| t.Fatal("timed out waiting for debounced refresh") | ||
| } | ||
|
|
||
| select { | ||
| case got := <-events: | ||
| t.Fatalf("unexpected extra refresh for %q", got) | ||
| case <-time.After(60 * time.Millisecond): | ||
| } | ||
| } | ||
|
|
||
| func TestTextDocumentDidChangeUsesLatestDocumentAfterDebounce(t *testing.T) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (testing): Add a test that verifies no further refreshes occur after the debouncer is stopped (e.g. during shutdown) Given the new
This will verify that in-flight timers are cancelled and no index updates occur after shutdown. |
||
| server := &lspServer{ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (testing): Add tests that cover mixin-related behavior with the new debounced refresh flow This test covers
Suggested implementation: }
}
type trackingStorage struct {
Storage interface {
// Adjust these methods to match the actual storage interface.
// The important part is that this wrapper is used by lspServer so we can
// count how often mixin documents are loaded.
SaveDocument(ctx context.Context, uri lsp.DocumentURI, text string) error
LoadDocument(ctx context.Context, uri lsp.DocumentURI) (string, error)
}
loadCounts map[lsp.DocumentURI]int
}
func newTrackingStorage(underlying interface {
SaveDocument(ctx context.Context, uri lsp.DocumentURI, text string) error
LoadDocument(ctx context.Context, uri lsp.DocumentURI) (string, error)
}) *trackingStorage {
return &trackingStorage{
Storage: underlying,
loadCounts: make(map[lsp.DocumentURI]int),
}
}
func (s *trackingStorage) SaveDocument(ctx context.Context, uri lsp.DocumentURI, text string) error {
// Pass-through; we don't need to track writes for this test.
return s.Storage.SaveDocument(ctx, uri, text)
}
func (s *trackingStorage) LoadDocument(ctx context.Context, uri lsp.DocumentURI) (string, error) {
s.loadCounts[uri]++
return s.Storage.LoadDocument(ctx, uri)
}
func TestRefreshDocumentDebouncedMixins(t *testing.T) {
logger := logger // reuse the package-level test logger if available
baseStorage := newStorage()
tracking := newTrackingStorage(baseStorage)
server := &lspServer{
// NOTE: lspServer.storage must be compatible with trackingStorage's interface.
storage: tracking,
parser: newParser(logger),
index: newIndex(logger),
log: logger,
}
server.refresh = newDocumentDebouncer(20*time.Millisecond, server.refreshDocument)
defer server.refresh.Stop()
ctx := context.Background()
mainURI := lsp.DocumentURI("file:///tmp/lets.yaml")
mixin1 := lsp.DocumentURI("file:///tmp/mixin1.lets")
mixin2 := lsp.DocumentURI("file:///tmp/mixin2.lets")
mixin3 := lsp.DocumentURI("file:///tmp/mixin3.lets")
// Helper to trigger a debounced refresh via textDocument/didChange using the
// same entrypoint as the other tests.
changeMain := func(text string) {
params := &lsp.DidChangeTextDocumentParams{
TextDocument: lsp.VersionedTextDocumentIdentifier{
TextDocumentIdentifier: lsp.TextDocumentIdentifier{URI: mainURI},
},
ContentChanges: []lsp.TextDocumentContentChangeEvent{
{Text: text},
},
}
if err := server.textDocumentDidChange(ctx, params); err != nil {
t.Fatalf("textDocumentDidChange: %v", err)
}
time.Sleep(50 * time.Millisecond) // wait for debounce to fire
}
// 1) Start with a document that uses mixin1.
changeMain("uses:\n - " + string(mixin1) + "\n")
if got := tracking.loadCounts[mixin1]; got != 1 {
t.Fatalf("expected mixin1 to be loaded once on initial refresh, got %d", got)
}
if got := tracking.loadCounts[mixin2]; got != 0 {
t.Fatalf("expected mixin2 to not be loaded yet, got %d", got)
}
if got := tracking.loadCounts[mixin3]; got != 0 {
t.Fatalf("expected mixin3 to not be loaded yet, got %d", got)
}
// 2) Change the document to still use mixin1 and also use mixin2.
changeMain("uses:\n - " + string(mixin1) + "\n - " + string(mixin2) + "\n")
// mixin1 is still in use but should not be redundantly reloaded.
if got := tracking.loadCounts[mixin1]; got != 1 {
t.Fatalf("expected mixin1 to not be reloaded, still 1 load; got %d", got)
}
// mixin2 is newly added and should be loaded exactly once.
if got := tracking.loadCounts[mixin2]; got != 1 {
t.Fatalf("expected mixin2 to be loaded once after being added, got %d", got)
}
// 3) Pre-load mixin3 into storage to simulate it already being managed.
if err := tracking.SaveDocument(ctx, mixin3, "command: mixin3"); err != nil {
t.Fatalf("preloading mixin3 into storage: %v", err)
}
// Now change the document to use mixin1, mixin2, and mixin3.
changeMain("uses:\n - " + string(mixin1) + "\n - " + string(mixin2) + "\n - " + string(mixin3) + "\n")
// mixin1 and mixin2 are still in use; no extra loads.
if got := tracking.loadCounts[mixin1]; got != 1 {
t.Fatalf("expected mixin1 to still have exactly 1 load, got %d", got)
}
if got := tracking.loadCounts[mixin2]; got != 1 {
t.Fatalf("expected mixin2 to still have exactly 1 load, got %d", got)
}
// mixin3 was already present in storage and should be skipped by loadMixins.
if got := tracking.loadCounts[mixin3]; got != 0 {
t.Fatalf("expected mixin3 to be skipped because it is already in storage, got %d loads", got)
}
}
func TestTextDocumentDidChangeUsesLatestDocumentAfterDebounce(t *testing.T) {
server := &lspServer{
storage: newStorage(),
parser: newParser(logger),
index: newIndex(logger),
log: logger,
}
server.refresh = newDocumentDebouncer(20*time.Millisecond, server.refreshDocument)
defer server.refresh.Stop()
params := &lsp.DidChangeTextDocumentParams{
TextDocument: lsp.VersionedTextDocumentIdentifier{
TextDocumentIdentifier: lsp.TextDocumentIdentifier{URI: "file:///tmp/lets.yaml"},
},The above changes assume certain shapes of the storage and server APIs that may differ slightly from your actual code:
Once you align
|
||
| storage: newStorage(), | ||
| parser: newParser(logger), | ||
| index: newIndex(logger), | ||
| log: logger, | ||
| } | ||
| server.refresh = newDocumentDebouncer(20*time.Millisecond, server.refreshDocument) | ||
| defer server.refresh.Stop() | ||
|
|
||
| params := &lsp.DidChangeTextDocumentParams{ | ||
| TextDocument: lsp.VersionedTextDocumentIdentifier{ | ||
| TextDocumentIdentifier: lsp.TextDocumentIdentifier{URI: "file:///tmp/lets.yaml"}, | ||
| }, | ||
| } | ||
|
|
||
| params.ContentChanges = []any{ | ||
| lsp.TextDocumentContentChangeEventWhole{ | ||
| Text: `commands: | ||
| build: | ||
| cmd: echo build`, | ||
| }, | ||
| } | ||
|
|
||
| if err := server.textDocumentDidChange(&glsp.Context{}, params); err != nil { | ||
| t.Fatalf("first textDocumentDidChange() error = %v", err) | ||
| } | ||
|
|
||
| params.ContentChanges = []any{ | ||
| lsp.TextDocumentContentChangeEventWhole{ | ||
| Text: `commands: | ||
| release: | ||
| cmd: echo release`, | ||
| }, | ||
| } | ||
|
|
||
| if err := server.textDocumentDidChange(&glsp.Context{}, params); err != nil { | ||
| t.Fatalf("second textDocumentDidChange() error = %v", err) | ||
| } | ||
|
|
||
| deadline := time.Now().Add(300 * time.Millisecond) | ||
| for time.Now().Before(deadline) { | ||
| if _, ok := server.index.findCommand("release"); ok { | ||
| if _, ok := server.index.findCommand("build"); ok { | ||
| t.Fatal("expected stale command to be removed after debounced refresh") | ||
| } | ||
|
|
||
| return | ||
| } | ||
|
|
||
| time.Sleep(10 * time.Millisecond) | ||
| } | ||
|
|
||
| t.Fatal("timed out waiting for debounced document refresh") | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion (testing): Consider making the debounce timing assertions more robust to avoid flaky tests
These tests depend on tight, hard-coded timing (20ms delay with 60–300ms timeouts), which can be fragile on slower CI runners. To make them more resilient, consider: (1) increasing the timeout safety margin (e.g., 5–10× the debounce delay), and/or (2) extracting the timing values into constants so they can be adjusted centrally if flakes occur.
Suggested implementation:
I only see part of the file. To fully apply the suggestion, you should:
time.After(60 * time.Millisecond)withtime.After(testDebounceTimeoutShort).time.After(300 * time.Millisecond)(or similar) withtime.After(testDebounceTimeoutLong)or introduce additional constants if you need more distinct timeout categories.newDocumentDebouncer(<literal> * time.Millisecond, ...)calls withnewDocumentDebouncer(testDebounceDelay, ...)unless a test intentionally uses a different delay.This will centralize timing configuration and make it easy to tweak if you ever see CI flakes.