Skip to content
160 changes: 160 additions & 0 deletions internal/lsp/cache_race_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package lsp

import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"testing"

"go.lsp.dev/protocol"
)

// TestCacheConcurrentAccess exercises the parse cache under concurrent handler
// calls and concurrent DidChange mutations. The test asserts no data race by
// passing under `go test -race`.
func TestCacheConcurrentAccess(t *testing.T) {
srv, _, dir := setupServer(t)
srv.cacheEnabled.Store(true)

// Build a small workspace: base.ridl defines a struct/error; two importers
// reference it. All three are written to disk so parsePath can read them.
baseContent := `webrpc = v1

name = racetest
version = v0.0.1

struct Point
- x: int32
- y: int32

error 100 BadInput "bad input" HTTP 400
`
importer1Content := `webrpc = v1

name = importer1
version = v0.0.1

import
- path = base.ridl
`
importer2Content := `webrpc = v1

name = importer2
version = v0.0.1

import
- path = base.ridl
`

basePath := filepath.Join(dir, "base.ridl")
imp1Path := filepath.Join(dir, "importer1.ridl")
imp2Path := filepath.Join(dir, "importer2.ridl")
for path, content := range map[string]string{
basePath: baseContent,
imp1Path: importer1Content,
imp2Path: importer2Content,
} {
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}

// Open base.ridl so DidChange has a registered document to churn.
baseURI := protocol.DocumentURI(fileURI(basePath))
ctx := context.Background()
if err := srv.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{
TextDocument: protocol.TextDocumentItem{
URI: baseURI,
Text: baseContent,
Version: 1,
},
}); err != nil {
t.Fatal(err)
}

// Put the cursor on the Point type definition so References runs a real
// cross-file search, walking the CLOSED importer files through
// parsePathForNavigation -> parsePath -> the session parse cache. A header
// position resolves no target and would never reach the cache.
pointPos := positionAt(t, baseContent, "Point")

// Precondition: a closed-file parse must populate the session cache, else the
// concurrent loop below would not exercise the cache path it claims to test.
if got := srv.parsePath(ctx, imp1Path); got == nil || got.Root == nil {
t.Fatal("precondition: closed importer parse must succeed")
}
if _, ok := srv.parseCache.get(imp1Path, srv.gen.Load()); !ok {
t.Fatal("precondition: session parse cache was not populated by a closed-file parse")
}

const (
numReaders = 16
numChangers = 2
iters = 50
)

var wg sync.WaitGroup

// Reader goroutines call a mix of read-path handlers.
for i := 0; i < numReaders; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iters; j++ {
pos := pointPos
switch id % 4 {
case 0:
_, _ = srv.References(ctx, &protocol.ReferenceParams{
TextDocumentPositionParams: protocol.TextDocumentPositionParams{
TextDocument: protocol.TextDocumentIdentifier{URI: baseURI},
Position: pos,
},
})
case 1:
_, _ = srv.Definition(ctx, &protocol.DefinitionParams{
TextDocumentPositionParams: protocol.TextDocumentPositionParams{
TextDocument: protocol.TextDocumentIdentifier{URI: baseURI},
Position: pos,
},
})
case 2:
_, _ = srv.Hover(ctx, &protocol.HoverParams{
TextDocumentPositionParams: protocol.TextDocumentPositionParams{
TextDocument: protocol.TextDocumentIdentifier{URI: baseURI},
Position: pos,
},
})
case 3:
_, _ = srv.CodeLens(ctx, &protocol.CodeLensParams{
TextDocument: protocol.TextDocumentIdentifier{URI: baseURI},
})
}
}
}(i)
}

// Changer goroutines fire DidChange to churn the generation counter.
for i := 0; i < numChangers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iters; j++ {
ver := int32(2 + id*iters + j)
_ = srv.DidChange(ctx, &protocol.DidChangeTextDocumentParams{
TextDocument: protocol.VersionedTextDocumentIdentifier{
TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: baseURI},
Version: ver,
},
ContentChanges: []protocol.TextDocumentContentChangeEvent{
{Text: fmt.Sprintf("%s\n# churn %d\n", baseContent, j)},
},
})
}
}(i)
}

wg.Wait()
// No assertion needed: a data race would be caught by the -race detector.
}
126 changes: 126 additions & 0 deletions internal/lsp/candidate_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package lsp

import (
"os"
"path/filepath"
"testing"
)

// TestCandidatePathCacheGenSemantics unit-tests get/put semantics directly on
// candidatePathCache, mirroring the parseCache test.
func TestCandidatePathCacheGenSemantics(t *testing.T) {
c := newCandidatePathCache()
paths := []string{"a.ridl", "b.ridl"}

// Miss on empty cache.
if _, ok := c.get(1); ok {
t.Fatal("expected miss on empty cache")
}

// Put + hit at same gen.
c.put(1, paths)
got, ok := c.get(1)
if !ok {
t.Fatal("expected hit at gen 1")
}
if len(got) != len(paths) {
t.Fatalf("expected %d paths, got %d", len(paths), len(got))
}

// Gen mismatch is a miss.
if _, ok := c.get(2); ok {
t.Fatal("expected miss at newer gen")
}

// Roll forward: put at gen 2 replaces gen 1.
newPaths := []string{"c.ridl"}
c.put(2, newPaths)
if _, ok := c.get(1); ok {
t.Fatal("expected gen-1 entry dropped after roll-forward")
}
if got2, ok := c.get(2); !ok || len(got2) != 1 {
t.Fatal("expected gen-2 entry present")
}

// Stale put (older gen) is ignored.
c.put(1, paths)
if _, ok := c.get(1); ok {
t.Fatal("expected stale put at gen 1 to be ignored")
}
// Gen-2 entry must be unchanged after stale put.
if _, ok := c.get(2); !ok {
t.Fatal("expected gen-2 entry to survive stale put")
}

// Empty-but-valid slice at gen 3 is distinguishable from "not cached".
c.put(3, []string{})
emptyPaths, ok := c.get(3)
if !ok {
t.Fatal("expected hit for empty-but-valid entry at gen 3")
}
if emptyPaths == nil {
t.Fatal("expected non-nil (but empty) slice for empty-but-valid entry")
}
}

const candidateRIDL = `webrpc = v1

name = candidatetest
version = v0.1.0

struct Foo
- id: uint64
`

// TestCandidatePathsCachedAtStableGen verifies that:
// 1. A second call at the same gen returns the cached list (new on-disk file
// created between the two calls is NOT visible).
// 2. After srv.gen.Add(1) a fresh walk occurs and the new file IS present.
func TestCandidatePathsCachedAtStableGen(t *testing.T) {
srv, _, dir := setupServer(t)
srv.cacheEnabled.Store(true)

// Seed one open document so the workspace is non-empty.
seedPath := filepath.Join(dir, "seed.ridl")
if err := os.WriteFile(seedPath, []byte(candidateRIDL), 0o644); err != nil {
t.Fatal(err)
}

// First call: populates the candidate-path cache.
r1 := srv.referenceCandidatePaths()

// Write a NEW .ridl file directly to disk — no DidOpen, no watcher event,
// so the gen does not advance.
newPath := filepath.Join(dir, "new_file.ridl")
if err := os.WriteFile(newPath, []byte(candidateRIDL), 0o644); err != nil {
t.Fatal(err)
}

// Second call at same gen: must return cached list, new file must NOT appear.
r2 := srv.referenceCandidatePaths()
for _, p := range r2 {
if p == newPath {
t.Fatal("new_file.ridl must not appear in cached result at same gen")
}
}

// Ensure the lists agree in length (same gen → same cached object).
if len(r2) != len(r1) {
t.Fatalf("expected same length on cache hit: r1=%d r2=%d", len(r1), len(r2))
}

// Bump the gen — next call must re-walk and include the new file.
srv.gen.Add(1)
r3 := srv.referenceCandidatePaths()

found := false
for _, p := range r3 {
if p == newPath {
found = true
break
}
}
if !found {
t.Fatalf("new_file.ridl must appear after gen bump; got: %v", r3)
}
}
45 changes: 41 additions & 4 deletions internal/lsp/definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,51 @@ func (s *Server) parsePathForNavigation(path string) *ridl.ParseResult {
}

func (s *Server) parsePath(ctx context.Context, path string) *ridl.ParseResult {
if doc, ok := s.docs.FindByPath(path); ok && doc.Result != nil && doc.Result.Root != nil {
return doc.Result
if ctx.Err() != nil {
return nil
}

s.workspaceMu.RLock()
doc, open := s.docs.FindByPath(path)
if open {
if doc.Result != nil && doc.Result.Root != nil {
s.workspaceMu.RUnlock()
return doc.Result
}
overlays := s.overlayContents()
s.workspaceMu.RUnlock()
result, err := s.parser.Parse(ctx, s.workspace.Root(), path, overlays)
if err != nil || ctx.Err() != nil {
return nil
}
return result
}

result, err := s.parser.Parse(ctx, s.workspace.Root(), path, s.overlayContents())
if err != nil {
if !s.cacheEnabled.Load() {
overlays := s.overlayContents()
s.workspaceMu.RUnlock()
result, err := s.parser.Parse(ctx, s.workspace.Root(), path, overlays)
if err != nil || ctx.Err() != nil {
return nil
}
return result
}

gen := s.gen.Load()
if result, ok := s.parseCache.get(path, gen); ok {
s.workspaceMu.RUnlock()
return result
}
overlays := s.overlayContents()
s.workspaceMu.RUnlock()

result, err := s.parser.Parse(ctx, s.workspace.Root(), path, overlays)
if err != nil || ctx.Err() != nil {
return nil
}
if s.gen.Load() == gen {
s.parseCache.put(path, gen, result)
}
return result
}

Expand Down
4 changes: 2 additions & 2 deletions internal/lsp/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ func (s *Server) importDiagnostics(ctx context.Context, doc *documents.Document,
importPath := importNode.Path().String()
resolvedPath := workspace.ResolveImportPath(doc.Path, importPath)

importResult, err := s.parser.Parse(ctx, s.workspace.Root(), resolvedPath, s.overlayContents())
if err != nil || importResult == nil || importResult.Root == nil {
importResult := s.parsePath(ctx, resolvedPath)
if importResult == nil || importResult.Root == nil {
continue
}

Expand Down
17 changes: 17 additions & 0 deletions internal/lsp/diagnostics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ type mockClient struct {
appliedEdit *protocol.ApplyWorkspaceEditParams
diagnostics map[string][]protocol.Diagnostic
semanticTokensRefreshes int
registrations []protocol.Registration
registerErr error
}

func newMockClient() *mockClient {
Expand Down Expand Up @@ -90,6 +92,21 @@ func (m *mockClient) lastAppliedEdit() *protocol.ApplyWorkspaceEditParams {
return m.appliedEdit
}

func (m *mockClient) RegisterCapability(_ context.Context, params *protocol.RegistrationParams) error {
m.mu.Lock()
defer m.mu.Unlock()
m.registrations = append(m.registrations, params.Registrations...)
return m.registerErr
}

func (m *mockClient) getRegistrations() []protocol.Registration {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]protocol.Registration, len(m.registrations))
copy(out, m.registrations)
return out
}

func setupServer(t *testing.T) (*Server, *mockClient, string) {
t.Helper()

Expand Down
Loading
Loading