Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,14 @@ func (s *Server) RefreshDiagnostics(ctx context.Context) error {
return nil
}

if _, err := sendClientRequest(ctx, s, lsproto.WorkspaceDiagnosticRefreshInfo, lsproto.NoParams{}); err != nil {
if err := ctx.Err(); err != nil {
return err
}

// Fire-and-forget: the client always returns null, and waiting for the response
// can cause the server to hang if the client is slow or unresponsive.
// Any response from the client will be silently ignored by the read loop.
if err := sendClientRequestFireAndForget(s, lsproto.WorkspaceDiagnosticRefreshInfo, lsproto.NoParams{}); err != nil {
return fmt.Errorf("failed to refresh diagnostics: %w", err)
Comment thread
andrewbranch marked this conversation as resolved.
Comment thread
andrewbranch marked this conversation as resolved.
}

Expand Down Expand Up @@ -587,6 +594,16 @@ func sendClientRequest[Req, Resp any](ctx context.Context, s *Server, info lspro
}
}

// sendClientRequestFireAndForget sends a request to the client without waiting for a response.
// The response, if any, will be silently ignored by the read loop since no pending channel is registered.
// This means any error returned by the client will not be observed. Use only for requests where the
// response value is not needed (e.g., the client always returns null).
func sendClientRequestFireAndForget[Req, Resp any](s *Server, info lsproto.RequestInfo[Req, Resp], params Req) error {
id := jsonrpc.NewIDString(fmt.Sprintf("ts%d", s.clientSeq.Add(1)))
req := info.NewRequestMessage(id, params)
return s.send(req.Message())
}

func (s *Server) sendResult(id *jsonrpc.ID, result any) error {
return s.sendResponse(&lsproto.ResponseMessage{
ID: id,
Expand Down
106 changes: 79 additions & 27 deletions internal/project/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ const (
UpdateReasonIdleCleanDiskCache
)

// watchRequestTimeout is the maximum time to wait for the client to respond to
// a WatchFiles or UnwatchFiles request while holding the watches mutex.
const watchRequestTimeout = time.Second
Comment thread
andrewbranch marked this conversation as resolved.

// SessionOptions are the immutable initialization options for a session.
// Snapshots may reference them as a pointer since they never change.
type SessionOptions struct {
Expand Down Expand Up @@ -156,8 +160,7 @@ type Session struct {

// watches tracks the current watch globs and how many individual WatchedFiles
// are using each glob.
watches map[fileSystemWatcherKey]*fileSystemWatcherValue
watchesMu sync.Mutex
watches *watchRegistry

// globalDiagPublishPending is set to true when a global diagnostics publish
// task should be enqueued. It is reset when the task runs, coalescing multiple
Expand Down Expand Up @@ -222,7 +225,7 @@ func NewSession(init *SessionInit) *Session {
initialUserPreferences: lsutil.NewDefaultUserPreferences(),
workspaceUserPreferences: lsutil.NewDefaultUserPreferences(),
pendingATAChanges: make(map[tspath.Path]*ATAStateChange),
watches: make(map[fileSystemWatcherKey]*fileSystemWatcherValue),
watches: newWatchRegistry(),
}

if init.Options.TypingsLocation != "" && init.NpmExecutor != nil {
Expand Down Expand Up @@ -1133,29 +1136,36 @@ func (s *Session) WaitForBackgroundTasks() {

func updateWatch[T any](ctx context.Context, session *Session, logger logging.Logger, oldWatcher, newWatcher *WatchedFiles[T]) []error {
var errors []error
session.watchesMu.Lock()
defer session.watchesMu.Unlock()
session.watches.mu.Lock()
defer session.watches.mu.Unlock()
if newWatcher != nil {
w := newWatcher.Watchers()
watchers := append(w.WorkspaceWatchers, w.OutsideWorkspaceWatchers...)
if len(watchers) > 0 {
// Dedupe watchers by key to prevent refcount inflation from
// duplicate patterns.
seen := make(map[fileSystemWatcherKey]bool, len(watchers))
var newWatchers collections.OrderedMap[WatcherID, *lsproto.FileSystemWatcher]
for i, watcher := range watchers {
key := toFileSystemWatcherKey(watcher)
value := session.watches[key]
globId := WatcherID(fmt.Sprintf("%s.%d", w.WatcherID, i))
if value == nil {
value = &fileSystemWatcherValue{id: globId}
session.watches[key] = value
if seen[key] {
continue
}
value.count++
if value.count == 1 {
seen[key] = true
globId := WatcherID(fmt.Sprintf("%s.%d", w.WatcherID, i))
if session.watches.Acquire(watcher, globId) {
newWatchers.Set(globId, watcher)
}
}
var watchErrors []error
for id, watcher := range newWatchers.Entries() {
if err := session.client.WatchFiles(ctx, id, []*lsproto.FileSystemWatcher{watcher}); err != nil {
errors = append(errors, err)
// Create a fresh timeout per client call so earlier calls
// don't consume the deadline for later ones.
callCtx, callCancel := context.WithTimeout(ctx, watchRequestTimeout)
err := session.client.WatchFiles(callCtx, id, []*lsproto.FileSystemWatcher{watcher})
callCancel()
if err != nil {
watchErrors = append(watchErrors, err)
} else if logger != nil {
Comment on lines +1139 to 1169
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateWatch creates a single watchCtx with a 1s deadline and reuses it for all WatchFiles/UnwatchFiles calls in this update. If multiple registrations/unregistrations are needed, later calls may inherit very little time remaining (e.g., first call takes 900ms → second gets ~100ms), causing avoidable timeouts even when the client is responsive. Consider creating a fresh per-request timeout context inside the WatchFiles/UnwatchFiles loops (or otherwise resetting the deadline per call) while still ensuring the mutex is not held indefinitely.

Copilot uses AI. Check for mistakes.
Comment on lines 1149 to 1169
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On WatchFiles error, updateWatch rolls back bookkeeping via session.watches.Release(watcher) exactly once for that watcher. If the input watcher list contains duplicate patterns (same fileSystemWatcherKey), Acquire will increment the refcount multiple times but only one client registration is attempted; a failed registration would then leave the refcount >0, preventing future retries from ever treating that watch as “new”. This is reachable because some glob lists are only sorted (not deduped) before becoming watchers. Consider deduping watcher keys before calling Acquire, or tracking how many increments were applied per key and fully rolling them back on registration failure.

Copilot uses AI. Check for mistakes.
if oldWatcher == nil {
logger.Log(fmt.Sprintf("Added new watch: %s", id))
Expand All @@ -1166,6 +1176,19 @@ func updateWatch[T any](ctx context.Context, session *Session, logger logging.Lo
logger.Log("")
}
}
if len(watchErrors) > 0 {
// Roll back ALL newly-acquired watchers on any failure to keep
// refcounts clean. On retry, Acquire will see them as new again.
// Re-registering an already-registered watcher with the client
// is harmless (registerCapability with the same ID replaces it).
for _, watcher := range newWatchers.Entries() {
session.watches.Release(watcher)
}
session.watches.MarkPending(w.WatcherID)
errors = append(errors, watchErrors...)
} else {
session.watches.ClearPending(w.WatcherID)
}
if len(w.IgnoredPaths) > 0 {
logger.Logf("%d paths ineligible for watching", len(w.IgnoredPaths))
if logger.IsVerbose() {
Expand All @@ -1180,22 +1203,17 @@ func updateWatch[T any](ctx context.Context, session *Session, logger logging.Lo
w := oldWatcher.Watchers()
watchers := append(w.WorkspaceWatchers, w.OutsideWorkspaceWatchers...)
if len(watchers) > 0 {
var removedWatchers []WatcherID
var removedIDs []WatcherID
for _, watcher := range watchers {
key := toFileSystemWatcherKey(watcher)
value := session.watches[key]
if value == nil {
continue
}
if value.count <= 1 {
delete(session.watches, key)
removedWatchers = append(removedWatchers, value.id)
} else {
value.count--
if id, removed := session.watches.Release(watcher); removed {
removedIDs = append(removedIDs, id)
}
}
for _, id := range removedWatchers {
if err := session.client.UnwatchFiles(ctx, id); err != nil {
for _, id := range removedIDs {
callCtx, callCancel := context.WithTimeout(ctx, watchRequestTimeout)
err := session.client.UnwatchFiles(callCtx, id)
callCancel()
if err != nil {
errors = append(errors, err)
} else if logger != nil && newWatcher == nil {
logger.Log(fmt.Sprintf("Removed watch: %s", id))
Expand Down Expand Up @@ -1226,6 +1244,19 @@ func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) er
errors = append(errors, updateWatch(ctx, s, s.logger, oldEntry.rootFilesWatch, newEntry.rootFilesWatch)...)
},
)
// Retry config watchers whose IDs didn't change but whose previous registration failed.
for path, newEntry := range newSnapshot.ConfigFileRegistry.configs {
if oldEntry, ok := oldSnapshot.ConfigFileRegistry.configs[path]; ok {
if oldEntry.rootFilesWatch.ID() == newEntry.rootFilesWatch.ID() {
s.watches.mu.Lock()
isPending := s.watches.IsPending(newEntry.rootFilesWatch.ID())
s.watches.mu.Unlock()
if isPending {
errors = append(errors, updateWatch(ctx, s, s.logger, nil, newEntry.rootFilesWatch)...)
}
}
Comment on lines +1247 to +1257
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pending retry path calls updateWatch(ctx, s, ..., nil, newWatcher). If the original updateWatch call partially succeeded (some WatchFiles calls returned nil before a later one timed out), the registry will still contain refcounts for the successful watchers. Retrying with oldWatcher==nil will Acquire again and permanently increment those refcounts (no matching Release), which can prevent future UnwatchFiles from ever firing and leak client registrations. To keep refcounts consistent, consider making the failure path roll back the entire parent watcher’s bookkeeping when any error occurs (not just the failed watcher), or implement a retry helper that registers only missing keys without incrementing refcounts for already-tracked entries.

Copilot uses AI. Check for mistakes.
}
}

collections.DiffOrderedMaps(
oldSnapshot.ProjectCollection.ProjectsByPath(),
Expand All @@ -1241,15 +1272,36 @@ func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) er
func(_ tspath.Path, oldProject, newProject *Project) {
if oldProject.programFilesWatch.ID() != newProject.programFilesWatch.ID() {
errors = append(errors, updateWatch(ctx, s, s.logger, oldProject.programFilesWatch, newProject.programFilesWatch)...)
} else {
s.watches.mu.Lock()
isPending := s.watches.IsPending(newProject.programFilesWatch.ID())
s.watches.mu.Unlock()
if isPending {
errors = append(errors, updateWatch(ctx, s, s.logger, nil, newProject.programFilesWatch)...)
}
}
if oldProject.typingsWatch.ID() != newProject.typingsWatch.ID() {
errors = append(errors, updateWatch(ctx, s, s.logger, oldProject.typingsWatch, newProject.typingsWatch)...)
} else {
s.watches.mu.Lock()
isPending := s.watches.IsPending(newProject.typingsWatch.ID())
s.watches.mu.Unlock()
if isPending {
errors = append(errors, updateWatch(ctx, s, s.logger, nil, newProject.typingsWatch)...)
}
}
},
)

if oldSnapshot.autoImportsWatch.ID() != newSnapshot.autoImportsWatch.ID() {
errors = append(errors, updateWatch(ctx, s, s.logger, oldSnapshot.autoImportsWatch, newSnapshot.autoImportsWatch)...)
} else {
s.watches.mu.Lock()
isPending := s.watches.IsPending(newSnapshot.autoImportsWatch.ID())
s.watches.mu.Unlock()
if isPending {
errors = append(errors, updateWatch(ctx, s, s.logger, nil, newSnapshot.autoImportsWatch)...)
}
}

if len(errors) > 0 {
Expand Down
72 changes: 72 additions & 0 deletions internal/project/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,78 @@ type fileSystemWatcherValue struct {
id WatcherID
}

// watchRegistry tracks the current watch globs and how many individual
// WatchedFiles reference each glob. It provides ref-count helpers so callers
// don't manipulate the map directly.
//
// All methods require the caller to hold mu. Call mu.Lock/Unlock around
// groups of operations that must be atomic (e.g. Acquire + WatchFiles +
// rollback on failure).
type watchRegistry struct {
mu sync.Mutex
entries map[fileSystemWatcherKey]*fileSystemWatcherValue
pending map[WatcherID]struct{}
}

func newWatchRegistry() *watchRegistry {
return &watchRegistry{
entries: make(map[fileSystemWatcherKey]*fileSystemWatcherValue),
pending: make(map[WatcherID]struct{}),
}
}

// Acquire increments the ref count for a watcher. If this is the first
// reference (count goes from 0 to 1), it returns true so the caller knows
// to register the watcher with the client.
// Must be called with mu held.
func (r *watchRegistry) Acquire(watcher *lsproto.FileSystemWatcher, id WatcherID) (isNew bool) {
key := toFileSystemWatcherKey(watcher)
value := r.entries[key]
if value == nil {
value = &fileSystemWatcherValue{id: id}
r.entries[key] = value
}
value.count++
return value.count == 1
}

Comment on lines +32 to +66
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

watchRegistry is documented as guarding concurrent access with its mutex, but Acquire/Release/MarkPending/ClearPending do not lock r.mu (only IsPending does). Since session.go now locks session.watches.mu directly, the locking discipline is split across call sites, which makes it easy to accidentally call these methods without holding the mutex (data race) and undermines the stated encapsulation. Consider either (1) moving the locking inside watchRegistry methods (and removing external r.mu access), or (2) updating the type/method docs and API to make the required locking explicit (e.g., AcquireLocked/ReleaseLocked or a WithLock helper) so future callers can’t misuse it.

Copilot uses AI. Check for mistakes.
// Release decrements the ref count for a watcher. If no references remain,
// the entry is removed and the function returns the WatcherID and true so
// the caller knows to unregister the watcher from the client.
// Must be called with mu held.
func (r *watchRegistry) Release(watcher *lsproto.FileSystemWatcher) (id WatcherID, removed bool) {
key := toFileSystemWatcherKey(watcher)
value := r.entries[key]
if value == nil {
return "", false
}
if value.count <= 1 {
delete(r.entries, key)
return value.id, true
}
value.count--
return "", false
}

// MarkPending records that a watcher's registration failed and needs retry.
// Must be called with mu held.
func (r *watchRegistry) MarkPending(id WatcherID) {
r.pending[id] = struct{}{}
}

// ClearPending removes a watcher from the pending set after successful registration.
// Must be called with mu held.
func (r *watchRegistry) ClearPending(id WatcherID) {
delete(r.pending, id)
}

// IsPending returns true if the watcher needs retry due to a previous failure.
// Must be called with mu held.
func (r *watchRegistry) IsPending(id WatcherID) bool {
_, ok := r.pending[id]
return ok
}

type PatternsAndIgnored struct {
directoriesOutsideWorkspace []string
patternsInsideWorkspace []string
Expand Down
Loading
Loading