From cc371ac53bfc1afb9f4bd1cb8dab66353e1ca7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Sun, 21 Jun 2026 00:43:22 +0200 Subject: [PATCH 1/7] feat(lsp): add gen-keyed session parse cache + server state --- internal/lsp/parse_cache.go | 47 ++++++++++++++++++++++++++++++++ internal/lsp/parse_cache_test.go | 44 ++++++++++++++++++++++++++++++ internal/lsp/server.go | 3 ++ 3 files changed, 94 insertions(+) create mode 100644 internal/lsp/parse_cache.go create mode 100644 internal/lsp/parse_cache_test.go diff --git a/internal/lsp/parse_cache.go b/internal/lsp/parse_cache.go new file mode 100644 index 0000000..ddea695 --- /dev/null +++ b/internal/lsp/parse_cache.go @@ -0,0 +1,47 @@ +package lsp + +import ( + "sync" + + ridl "github.com/webrpc/ridl-lsp/internal/ridl" +) + +// parseCache holds parse results for CLOSED workspace files belonging to a single +// workspace generation. Any generation change drops the whole map, because a +// closed file's parse depends on the overlay+disk state of its entire import graph. +// Cached *ridl.ParseResult values (Root and Schema) are READ-ONLY. +type parseCache struct { + mu sync.Mutex + gen uint64 + entries map[string]*ridl.ParseResult +} + +func newParseCache() *parseCache { + return &parseCache{entries: map[string]*ridl.ParseResult{}} +} + +func (c *parseCache) get(path string, gen uint64) (*ridl.ParseResult, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if c.gen != gen { + return nil, false + } + result, ok := c.entries[path] + return result, ok +} + +func (c *parseCache) put(path string, gen uint64, result *ridl.ParseResult) { + if result == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if gen < c.gen { + return + } + if gen > c.gen { + c.entries = map[string]*ridl.ParseResult{} + c.gen = gen + } + c.entries[path] = result +} diff --git a/internal/lsp/parse_cache_test.go b/internal/lsp/parse_cache_test.go new file mode 100644 index 0000000..052551b --- /dev/null +++ b/internal/lsp/parse_cache_test.go @@ -0,0 +1,44 @@ +package lsp + +import ( + "testing" + + ridl "github.com/webrpc/ridl-lsp/internal/ridl" +) + +func TestParseCacheGetPutGenSemantics(t *testing.T) { + c := newParseCache() + r := &ridl.ParseResult{} + + // Miss on empty. + if _, ok := c.get("a.ridl", 1); ok { + t.Fatal("expected miss on empty cache") + } + // Put + hit at same gen. + c.put("a.ridl", 1, r) + if got, ok := c.get("a.ridl", 1); !ok || got != r { + t.Fatal("expected hit at gen 1") + } + // Gen mismatch is a miss (whole-gen invalidation). + if _, ok := c.get("a.ridl", 2); ok { + t.Fatal("expected miss at newer gen") + } + // Roll forward: put at gen 2 drops gen-1 entries. + c.put("b.ridl", 2, r) + if _, ok := c.get("a.ridl", 2); ok { + t.Fatal("expected gen-1 entry dropped after roll-forward") + } + if got, ok := c.get("b.ridl", 2); !ok || got != r { + t.Fatal("expected hit for gen-2 entry") + } + // Stale put (older gen) is ignored. + c.put("c.ridl", 1, r) + if _, ok := c.get("c.ridl", 1); ok { + t.Fatal("expected stale put to be ignored") + } + // Nil result is never stored. + c.put("d.ridl", 2, nil) + if _, ok := c.get("d.ridl", 2); ok { + t.Fatal("expected nil result not stored") + } +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 49afd66..f735340 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -20,6 +20,8 @@ type Server struct { client protocol.Client logger *zap.Logger + parseCache *parseCache + shutdown atomic.Bool // exitProcess is os.Exit in production; injectable so the exit-code contract // can be tested without terminating the test binary. @@ -33,6 +35,7 @@ func NewServer(logger *zap.Logger) *Server { parser: ridlparser.NewParser(), logger: logger, exitProcess: os.Exit, + parseCache: newParseCache(), } } From c0497927c300be0a2aa4f7ac526c52a2d1001022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Sun, 21 Jun 2026 00:50:07 +0200 Subject: [PATCH 2/7] feat(lsp): bump workspace generation atomically on content events --- internal/lsp/generation_test.go | 53 +++++++++++++++++++++++++++++++++ internal/lsp/server.go | 15 ++++++++++ internal/lsp/stubs.go | 3 ++ 3 files changed, 71 insertions(+) create mode 100644 internal/lsp/generation_test.go diff --git a/internal/lsp/generation_test.go b/internal/lsp/generation_test.go new file mode 100644 index 0000000..8fd8877 --- /dev/null +++ b/internal/lsp/generation_test.go @@ -0,0 +1,53 @@ +package lsp + +import ( + "context" + "os" + "path/filepath" + "testing" + + "go.lsp.dev/protocol" +) + +func TestGenerationBumps(t *testing.T) { + srv, _, dir := setupServer(t) + ctx := context.Background() + path := filepath.Join(dir, "a.ridl") + content := "webrpc = v1\n\nname = t\nversion = v0.0.1\n\nstruct User\n - id: uint64\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + uri := fileURI(path) + + g0 := srv.gen.Load() + _ = srv.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{TextDocument: protocol.TextDocumentItem{URI: protocol.DocumentURI(uri), Text: content, Version: 1}}) + g1 := srv.gen.Load() + if g1 <= g0 { + t.Fatalf("DidOpen must bump gen: %d -> %d", g0, g1) + } + + _ = srv.DidChange(ctx, &protocol.DidChangeTextDocumentParams{ + TextDocument: protocol.VersionedTextDocumentIdentifier{TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: protocol.DocumentURI(uri)}, Version: 2}, + ContentChanges: []protocol.TextDocumentContentChangeEvent{{Text: content + "\nstruct B\n - u: User\n"}}, + }) + g2 := srv.gen.Load() + if g2 <= g1 { + t.Fatalf("DidChange must bump gen") + } + + _ = srv.DidSave(ctx, &protocol.DidSaveTextDocumentParams{TextDocument: protocol.TextDocumentIdentifier{URI: protocol.DocumentURI(uri)}}) + if srv.gen.Load() != g2 { + t.Fatal("DidSave must NOT bump gen") + } + + _ = srv.DidChangeWatchedFiles(ctx, &protocol.DidChangeWatchedFilesParams{Changes: []*protocol.FileEvent{{URI: protocol.DocumentURI(uri), Type: protocol.FileChangeTypeChanged}}}) + g3 := srv.gen.Load() + if g3 <= g2 { + t.Fatalf(".ridl DidChangeWatchedFiles must bump gen") + } + + _ = srv.DidClose(ctx, &protocol.DidCloseTextDocumentParams{TextDocument: protocol.TextDocumentIdentifier{URI: protocol.DocumentURI(uri)}}) + if srv.gen.Load() <= g3 { + t.Fatal("DidClose must bump gen") + } +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index f735340..e40c45b 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -3,6 +3,7 @@ package lsp import ( "context" "os" + "sync" "sync/atomic" "go.lsp.dev/protocol" @@ -22,6 +23,11 @@ type Server struct { parseCache *parseCache + // workspaceMu guards docs mutations and gen bumps so the two are always + // seen together by any reader that loads gen as a cache key. + workspaceMu sync.RWMutex + gen atomic.Uint64 + shutdown atomic.Bool // exitProcess is os.Exit in production; injectable so the exit-code contract // can be tested without terminating the test binary. @@ -144,7 +150,10 @@ func (s *Server) DidOpen(ctx context.Context, params *protocol.DidOpenTextDocume Version: params.TextDocument.Version, } + s.workspaceMu.Lock() s.docs.Set(doc) + s.gen.Add(1) + s.workspaceMu.Unlock() s.refreshOpenDocuments(ctx) return nil } @@ -163,7 +172,10 @@ func (s *Server) DidChange(ctx context.Context, params *protocol.DidChangeTextDo updated.Content = params.ContentChanges[len(params.ContentChanges)-1].Text updated.Version = params.TextDocument.Version updated.Result = nil + s.workspaceMu.Lock() s.docs.Set(&updated) + s.gen.Add(1) + s.workspaceMu.Unlock() s.refreshOpenDocuments(ctx) } @@ -172,7 +184,10 @@ func (s *Server) DidChange(ctx context.Context, params *protocol.DidChangeTextDo func (s *Server) DidClose(ctx context.Context, params *protocol.DidCloseTextDocumentParams) error { uri := string(params.TextDocument.URI) + s.workspaceMu.Lock() s.docs.Delete(uri) + s.gen.Add(1) + s.workspaceMu.Unlock() if s.client != nil { _ = s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{ URI: protocol.DocumentURI(uri), diff --git a/internal/lsp/stubs.go b/internal/lsp/stubs.go index e4cbc85..89f98a5 100644 --- a/internal/lsp/stubs.go +++ b/internal/lsp/stubs.go @@ -44,6 +44,9 @@ func (s *Server) DidChangeWatchedFiles(ctx context.Context, params *protocol.Did continue } + s.workspaceMu.Lock() + s.gen.Add(1) + s.workspaceMu.Unlock() s.refreshOpenDocuments(ctx) break } From 2380f2d31bf0112aa122ab6c2c786c04ce40631d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Sun, 21 Jun 2026 00:54:36 +0200 Subject: [PATCH 3/7] feat(lsp): register .ridl watchers and gate the cache on success --- internal/lsp/diagnostics_test.go | 17 +++++ internal/lsp/server.go | 29 ++++++++ internal/lsp/watcher_registration_test.go | 85 +++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 internal/lsp/watcher_registration_test.go diff --git a/internal/lsp/diagnostics_test.go b/internal/lsp/diagnostics_test.go index b501a60..75767c8 100644 --- a/internal/lsp/diagnostics_test.go +++ b/internal/lsp/diagnostics_test.go @@ -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 { @@ -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() diff --git a/internal/lsp/server.go b/internal/lsp/server.go index e40c45b..dab5d8e 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -28,6 +28,12 @@ type Server struct { workspaceMu sync.RWMutex gen atomic.Uint64 + // cacheEnabled is set true only after the client confirms watcher registration, + // ensuring the parse cache is only used when invalidation events are guaranteed. + cacheEnabled atomic.Bool + // watchSupported is captured from InitializeParams and stays immutable after Initialize returns. + watchSupported bool + shutdown atomic.Bool // exitProcess is os.Exit in production; injectable so the exit-code contract // can be tested without terminating the test binary. @@ -56,6 +62,11 @@ func (s *Server) Initialize(ctx context.Context, params *protocol.InitializePara s.workspace.SetRoot(params.RootPath) //nolint:staticcheck } + // Initialize runs once before any concurrent handler; plain field write is safe. + if ws := params.Capabilities.Workspace; ws != nil && ws.DidChangeWatchedFiles != nil { + s.watchSupported = ws.DidChangeWatchedFiles.DynamicRegistration + } + return &protocol.InitializeResult{ Capabilities: protocol.ServerCapabilities{ TextDocumentSync: &protocol.TextDocumentSyncOptions{ @@ -118,7 +129,25 @@ func (s *Server) Initialize(ctx context.Context, params *protocol.InitializePara }, nil } +// Initialized registers **/*.ridl file watchers and gates the parse cache on success. func (s *Server) Initialized(ctx context.Context, params *protocol.InitializedParams) error { + if !s.watchSupported || s.client == nil { + return nil + } + err := s.client.RegisterCapability(ctx, &protocol.RegistrationParams{ + Registrations: []protocol.Registration{{ + ID: "ridl-watch-files", + Method: "workspace/didChangeWatchedFiles", + RegisterOptions: protocol.DidChangeWatchedFilesRegistrationOptions{ + Watchers: []protocol.FileSystemWatcher{{GlobPattern: "**/*.ridl"}}, + }, + }}, + }) + if err != nil { + s.logger.Warn("ridl-lsp: file watcher registration failed; session parse cache disabled", zap.Error(err)) + return nil + } + s.cacheEnabled.Store(true) return nil } diff --git a/internal/lsp/watcher_registration_test.go b/internal/lsp/watcher_registration_test.go new file mode 100644 index 0000000..426a61e --- /dev/null +++ b/internal/lsp/watcher_registration_test.go @@ -0,0 +1,85 @@ +package lsp + +import ( + "context" + "errors" + "testing" + + "go.lsp.dev/protocol" +) + +// initializeWithWatchCap runs Initialize+Initialized for a server whose client +// advertises (or does not advertise) DidChangeWatchedFiles dynamic registration. +func initializeWithWatchCap(t *testing.T, srv *Server, dynamic bool) { + t.Helper() + ctx := context.Background() + + var workspaceCaps *protocol.WorkspaceClientCapabilities + if dynamic { + workspaceCaps = &protocol.WorkspaceClientCapabilities{ + DidChangeWatchedFiles: &protocol.DidChangeWatchedFilesWorkspaceClientCapabilities{ + DynamicRegistration: true, + }, + } + } + + _, err := srv.Initialize(ctx, &protocol.InitializeParams{ + Capabilities: protocol.ClientCapabilities{ + Workspace: workspaceCaps, + }, + }) + if err != nil { + t.Fatalf("Initialize: %v", err) + } + if err := srv.Initialized(ctx, &protocol.InitializedParams{}); err != nil { + t.Fatalf("Initialized: %v", err) + } +} + +func TestWatcherRegistration_CapabilityAdvertised_Success(t *testing.T) { + srv, client, _ := setupServer(t) + + initializeWithWatchCap(t, srv, true) + + regs := client.getRegistrations() + if len(regs) != 1 { + t.Fatalf("want 1 registration, got %d", len(regs)) + } + if regs[0].Method != "workspace/didChangeWatchedFiles" { + t.Errorf("want method workspace/didChangeWatchedFiles, got %q", regs[0].Method) + } + opts, ok := regs[0].RegisterOptions.(protocol.DidChangeWatchedFilesRegistrationOptions) + if !ok { + t.Fatalf("RegisterOptions wrong type: %T", regs[0].RegisterOptions) + } + if len(opts.Watchers) != 1 || opts.Watchers[0].GlobPattern != "**/*.ridl" { + t.Errorf("want watcher **/*.ridl, got %+v", opts.Watchers) + } + if !srv.cacheEnabled.Load() { + t.Error("cacheEnabled must be true after successful registration") + } +} + +func TestWatcherRegistration_CapabilityNotAdvertised(t *testing.T) { + srv, client, _ := setupServer(t) + + initializeWithWatchCap(t, srv, false) + + if regs := client.getRegistrations(); len(regs) != 0 { + t.Errorf("want 0 registrations, got %d", len(regs)) + } + if srv.cacheEnabled.Load() { + t.Error("cacheEnabled must be false when capability not advertised") + } +} + +func TestWatcherRegistration_CapabilityAdvertised_RegisterFails(t *testing.T) { + srv, client, _ := setupServer(t) + client.registerErr = errors.New("test: register denied") + + initializeWithWatchCap(t, srv, true) + + if srv.cacheEnabled.Load() { + t.Error("cacheEnabled must be false when RegisterCapability returns an error") + } +} From 12037df4e9bb7cfdfa1eda75a998ef15b201aa94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Sun, 21 Jun 2026 00:59:16 +0200 Subject: [PATCH 4/7] feat(lsp): serve closed-file parses from the session cache --- internal/lsp/definition.go | 45 ++++++++++- internal/lsp/parse_path_cache_test.go | 112 ++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 internal/lsp/parse_path_cache_test.go diff --git a/internal/lsp/definition.go b/internal/lsp/definition.go index 74c2c5c..ef89b91 100644 --- a/internal/lsp/definition.go +++ b/internal/lsp/definition.go @@ -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 } diff --git a/internal/lsp/parse_path_cache_test.go b/internal/lsp/parse_path_cache_test.go new file mode 100644 index 0000000..0e1537c --- /dev/null +++ b/internal/lsp/parse_path_cache_test.go @@ -0,0 +1,112 @@ +package lsp + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +const baseRIDLContent = `webrpc = v1 + +name = cachetest +version = v0.1.0 + +struct User + - id: uint64 +` + +func TestParsePathCachesClosedFile(t *testing.T) { + srv, _, dir := setupServer(t) + srv.cacheEnabled.Store(true) + + basePath := filepath.Join(dir, "base.ridl") + if err := os.WriteFile(basePath, []byte(baseRIDLContent), 0o644); err != nil { + t.Fatal(err) + } + + r1 := srv.parsePath(context.Background(), basePath) + if r1 == nil { + t.Fatal("expected non-nil result from first parse") + } + if r1.Root == nil { + t.Fatal("expected non-nil Root from first parse") + } + + r2 := srv.parsePath(context.Background(), basePath) + if r2 == nil { + t.Fatal("expected non-nil result from second parse") + } + // pointer identity proves cache hit + if r1 != r2 { + t.Fatal("expected cache hit: r1 and r2 should be the same pointer") + } +} + +func TestParsePathReparsesAfterGenBump(t *testing.T) { + srv, _, dir := setupServer(t) + srv.cacheEnabled.Store(true) + + basePath := filepath.Join(dir, "base.ridl") + if err := os.WriteFile(basePath, []byte(baseRIDLContent), 0o644); err != nil { + t.Fatal(err) + } + + r1 := srv.parsePath(context.Background(), basePath) + if r1 == nil { + t.Fatal("expected non-nil result from first parse") + } + + srv.gen.Add(1) + + r3 := srv.parsePath(context.Background(), basePath) + if r3 == nil { + t.Fatal("expected non-nil result after gen bump") + } + // gen bump must drop the cache → fresh pointer + if r3 == r1 { + t.Fatal("expected fresh parse after gen bump: r3 should differ from r1") + } +} + +func TestParsePathDoesNotCacheCanceledParse(t *testing.T) { + srv, _, dir := setupServer(t) + srv.cacheEnabled.Store(true) + + basePath := filepath.Join(dir, "base.ridl") + if err := os.WriteFile(basePath, []byte(baseRIDLContent), 0o644); err != nil { + t.Fatal(err) + } + + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + got := srv.parsePath(canceledCtx, basePath) + if got != nil { + t.Fatal("expected nil result for cancelled context") + } + + // cache must NOT be poisoned by the cancelled attempt + r := srv.parsePath(context.Background(), basePath) + if r == nil { + t.Fatal("expected non-nil result after cancelled attempt: cache should not be poisoned") + } +} + +func TestParsePathBypassesCacheWhenDisabled(t *testing.T) { + srv, _, dir := setupServer(t) + // cacheEnabled is false by default — do not enable it + + basePath := filepath.Join(dir, "base.ridl") + if err := os.WriteFile(basePath, []byte(baseRIDLContent), 0o644); err != nil { + t.Fatal(err) + } + + r := srv.parsePath(context.Background(), basePath) + if r == nil { + t.Fatal("expected non-nil result when cache disabled") + } + if r.Root == nil { + t.Fatal("expected non-nil Root when cache disabled") + } +} From 6929fb1d1f368f5c7632f5614127c64755d1b5dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Sun, 21 Jun 2026 01:06:17 +0200 Subject: [PATCH 5/7] feat(lsp): cache candidate paths and route import diagnostics through the cache --- internal/lsp/candidate_cache_test.go | 126 +++++++++++++++++++++++++++ internal/lsp/diagnostics.go | 4 +- internal/lsp/parse_cache.go | 37 ++++++++ internal/lsp/references.go | 22 +++++ internal/lsp/server.go | 16 ++-- 5 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 internal/lsp/candidate_cache_test.go diff --git a/internal/lsp/candidate_cache_test.go b/internal/lsp/candidate_cache_test.go new file mode 100644 index 0000000..c7d812c --- /dev/null +++ b/internal/lsp/candidate_cache_test.go @@ -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) + } +} diff --git a/internal/lsp/diagnostics.go b/internal/lsp/diagnostics.go index 64eba9e..6d0357a 100644 --- a/internal/lsp/diagnostics.go +++ b/internal/lsp/diagnostics.go @@ -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 } diff --git a/internal/lsp/parse_cache.go b/internal/lsp/parse_cache.go index ddea695..a134cf6 100644 --- a/internal/lsp/parse_cache.go +++ b/internal/lsp/parse_cache.go @@ -16,6 +16,43 @@ type parseCache struct { entries map[string]*ridl.ParseResult } +// candidatePathCache holds the last WalkDir result for a single workspace +// generation. The cached slice is READ-ONLY — callers must not mutate it. +// A valid=true entry at gen=0 is distinguishable from "never cached". +type candidatePathCache struct { + mu sync.Mutex + gen uint64 + paths []string + valid bool +} + +func newCandidatePathCache() *candidatePathCache { + return &candidatePathCache{} +} + +// get returns the cached path list if it was stored at exactly gen, otherwise false. +func (c *candidatePathCache) get(gen uint64) ([]string, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if !c.valid || c.gen != gen { + return nil, false + } + return c.paths, true +} + +// put stores paths at gen. Stale puts (gen < c.gen) are silently dropped so a +// concurrent walk that finishes late never overwrites a newer cache entry. +func (c *candidatePathCache) put(gen uint64, paths []string) { + c.mu.Lock() + defer c.mu.Unlock() + if gen < c.gen { + return + } + c.gen = gen + c.paths = paths + c.valid = true +} + func newParseCache() *parseCache { return &parseCache{entries: map[string]*ridl.ParseResult{}} } diff --git a/internal/lsp/references.go b/internal/lsp/references.go index 759e96d..5d1ac55 100644 --- a/internal/lsp/references.go +++ b/internal/lsp/references.go @@ -311,6 +311,28 @@ func (d *semanticDocument) identifierRangeInToken(token *ridl.TokenNode, pos pro } func (s *Server) referenceCandidatePaths() []string { + if !s.cacheEnabled.Load() { + return s.walkCandidatePaths() + } + + s.workspaceMu.RLock() + gen := s.gen.Load() + if cached, ok := s.candidatePathCache.get(gen); ok { + s.workspaceMu.RUnlock() + // Cached slice is READ-ONLY — callers must not mutate it. + return cached + } + s.workspaceMu.RUnlock() + + paths := s.walkCandidatePaths() + // Only store if gen hasn't advanced while we were walking. + if s.gen.Load() == gen { + s.candidatePathCache.put(gen, paths) + } + return paths +} + +func (s *Server) walkCandidatePaths() []string { seen := map[string]struct{}{} paths := make([]string, 0, len(s.docs.All())) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index dab5d8e..3c0011e 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -21,7 +21,8 @@ type Server struct { client protocol.Client logger *zap.Logger - parseCache *parseCache + parseCache *parseCache + candidatePathCache *candidatePathCache // workspaceMu guards docs mutations and gen bumps so the two are always // seen together by any reader that loads gen as a cache key. @@ -42,12 +43,13 @@ type Server struct { func NewServer(logger *zap.Logger) *Server { return &Server{ - docs: documents.NewStore(), - workspace: workspace.NewManager(), - parser: ridlparser.NewParser(), - logger: logger, - exitProcess: os.Exit, - parseCache: newParseCache(), + docs: documents.NewStore(), + workspace: workspace.NewManager(), + parser: ridlparser.NewParser(), + logger: logger, + exitProcess: os.Exit, + parseCache: newParseCache(), + candidatePathCache: newCandidatePathCache(), } } From 321df8f08b153561ac40815d1ab2d5a0bc0778ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Sun, 21 Jun 2026 01:11:31 +0200 Subject: [PATCH 6/7] test(lsp): race-test the shared parse cache under concurrent handlers --- internal/lsp/cache_race_test.go | 145 ++++++++++++++++++++++++++ internal/lsp/parse_path_cache_test.go | 8 +- 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 internal/lsp/cache_race_test.go diff --git a/internal/lsp/cache_race_test.go b/internal/lsp/cache_race_test.go new file mode 100644 index 0000000..72cefe5 --- /dev/null +++ b/internal/lsp/cache_race_test.go @@ -0,0 +1,145 @@ +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) + } + + 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 := protocol.Position{Line: 0, Character: 0} + 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. +} diff --git a/internal/lsp/parse_path_cache_test.go b/internal/lsp/parse_path_cache_test.go index 0e1537c..158443f 100644 --- a/internal/lsp/parse_path_cache_test.go +++ b/internal/lsp/parse_path_cache_test.go @@ -57,13 +57,19 @@ func TestParsePathReparsesAfterGenBump(t *testing.T) { t.Fatal("expected non-nil result from first parse") } + // r2 must hit the cache — same pointer proves the entry was populated. + r2 := srv.parsePath(context.Background(), basePath) + if r2 != r1 { + t.Fatal("expected cache hit before gen bump: r2 should be the same pointer as r1") + } + srv.gen.Add(1) r3 := srv.parsePath(context.Background(), basePath) + // gen bump must drop the cache → fresh non-nil pointer if r3 == nil { t.Fatal("expected non-nil result after gen bump") } - // gen bump must drop the cache → fresh pointer if r3 == r1 { t.Fatal("expected fresh parse after gen bump: r3 should differ from r1") } From c8312db93582016c100381ee3ca4d7eadcb20874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Sun, 21 Jun 2026 09:27:39 +0200 Subject: [PATCH 7/7] test(lsp): make the cache race test actually exercise the session cache --- internal/lsp/cache_race_test.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/lsp/cache_race_test.go b/internal/lsp/cache_race_test.go index 72cefe5..d1dbc60 100644 --- a/internal/lsp/cache_race_test.go +++ b/internal/lsp/cache_race_test.go @@ -74,6 +74,21 @@ import 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 @@ -88,7 +103,7 @@ import go func(id int) { defer wg.Done() for j := 0; j < iters; j++ { - pos := protocol.Position{Line: 0, Character: 0} + pos := pointPos switch id % 4 { case 0: _, _ = srv.References(ctx, &protocol.ReferenceParams{