diff --git a/mcp/CWM.RoslynNavigator/src/CWM.RoslynNavigator.csproj b/mcp/CWM.RoslynNavigator/src/CWM.RoslynNavigator.csproj
index ba937ec..6b60c5c 100644
--- a/mcp/CWM.RoslynNavigator/src/CWM.RoslynNavigator.csproj
+++ b/mcp/CWM.RoslynNavigator/src/CWM.RoslynNavigator.csproj
@@ -15,7 +15,7 @@
CWM.RoslynNavigator
- 0.7.0
+ 0.7.1
Mukesh Murugan
codewithmukesh
CWM.RoslynNavigator
diff --git a/mcp/CWM.RoslynNavigator/src/WorkspaceManager.cs b/mcp/CWM.RoslynNavigator/src/WorkspaceManager.cs
index eed30ec..bd7997c 100644
--- a/mcp/CWM.RoslynNavigator/src/WorkspaceManager.cs
+++ b/mcp/CWM.RoslynNavigator/src/WorkspaceManager.cs
@@ -11,8 +11,8 @@ namespace CWM.RoslynNavigator;
///
/// Manages the MSBuildWorkspace lifecycle: loading, on-demand refresh, and compilation caching.
-/// File watching is intentionally avoided — on Linux/WSL, recursive FileSystemWatcher creates
-/// one inotify watch per subdirectory (including bin/obj/.git), quickly exhausting the kernel limit.
+/// File watching is intentionally avoided — on Linux, recursive FileSystemWatcher creates
+/// one inotify watch per subdirectory, quickly exhausting the kernel limit for large solutions.
/// Instead, documents are refreshed on demand when tools are invoked.
///
public sealed class WorkspaceManager : IDisposable
@@ -24,13 +24,15 @@ public sealed class WorkspaceManager : IDisposable
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly ConcurrentDictionary _compilationCache = new();
private readonly ConcurrentDictionary _cacheAccessOrder = new();
- private readonly ConcurrentDictionary _knownFileTimestamps = new();
+ private readonly ConcurrentDictionary _knownDocuments = new();
private readonly ConcurrentDictionary _projectFileTimestamps = new();
private readonly ConcurrentDictionary _knownDocumentPaths = new(StringComparer.OrdinalIgnoreCase);
private long _accessCounter;
private int _rootsAttempted; // 0 = not tried, 1 = tried
private long _lastRefreshTicks;
+ private long _lastStructuralScanTicks;
private static readonly long RefreshCooldownTicks = TimeSpan.FromSeconds(5).Ticks;
+ private static readonly long StructuralScanCooldownTicks = TimeSpan.FromSeconds(60).Ticks;
private MSBuildWorkspace? _workspace;
private Solution? _solution;
@@ -199,7 +201,6 @@ public async Task> GetAllCompilationsAsync(Cancellati
{
if (State == WorkspaceState.Ready)
{
- // Refresh any source files that changed since the last tool call
await RefreshChangedDocumentsAsync(ct);
return null;
}
@@ -262,12 +263,14 @@ await Parallel.ForEachAsync(
///
/// Records the last-write time of every document and project file in the solution.
/// Called once after solution load to establish a baseline for staleness detection.
+ /// Stores file paths and project IDs alongside timestamps to avoid Roslyn lookups
+ /// during the per-call refresh hot path.
///
private void SnapshotFileTimestamps()
{
if (_solution is null) return;
- _knownFileTimestamps.Clear();
+ _knownDocuments.Clear();
_projectFileTimestamps.Clear();
_knownDocumentPaths.Clear();
@@ -276,88 +279,98 @@ private void SnapshotFileTimestamps()
var project = _solution.GetProject(projectId);
if (project is null) continue;
- if (project.FilePath is not null && File.Exists(project.FilePath))
+ if (project.FilePath is not null)
{
_projectFileTimestamps[project.FilePath] = File.GetLastWriteTimeUtc(project.FilePath);
}
foreach (var document in project.Documents)
{
- if (document.FilePath is not null && File.Exists(document.FilePath))
- {
- _knownFileTimestamps[document.Id] = File.GetLastWriteTimeUtc(document.FilePath);
- _knownDocumentPaths[document.FilePath] = 0;
- }
+ if (document.FilePath is null) continue;
+
+ var writeTime = File.GetLastWriteTimeUtc(document.FilePath);
+ // GetLastWriteTimeUtc returns year 1601 for non-existent files
+ if (writeTime.Year < 1900) continue;
+
+ _knownDocuments[document.Id] = new DocumentInfo(document.FilePath, projectId, writeTime);
+ _knownDocumentPaths[document.FilePath] = 0;
}
}
_logger.LogInformation("Captured timestamps for {Count} documents across {ProjectCount} projects",
- _knownFileTimestamps.Count, _projectFileTimestamps.Count);
+ _knownDocuments.Count, _projectFileTimestamps.Count);
}
+ private readonly record struct DocumentInfo(string FilePath, ProjectId ProjectId, DateTime LastWriteUtc);
+
///
- /// Refreshes the workspace to reflect on-disk changes. Skips if called within the
- /// cooldown window (5s) to avoid expensive filesystem scans on rapid-fire tool calls.
- /// Checks .csproj timestamps first (cheap), then scans for new files, then does
- /// incremental text updates for modified documents.
+ /// Refreshes the workspace to reflect on-disk changes. Uses tiered cooldowns to
+ /// keep per-call overhead low: .csproj + document timestamps every 5s, full
+ /// directory scan for new files every 60s.
///
- public async Task RefreshChangedDocumentsAsync(CancellationToken ct = default)
+ public async Task RefreshChangedDocumentsAsync(CancellationToken ct = default)
{
- if (_solution is null || _solutionPath is null) return false;
+ if (_solution is null || _solutionPath is null) return;
- // Skip if we refreshed recently — MCP tool calls often come in bursts
var now = DateTime.UtcNow.Ticks;
- var last = Interlocked.Read(ref _lastRefreshTicks);
- if (now - last < RefreshCooldownTicks) return false;
+
+ var lastRefresh = Interlocked.Read(ref _lastRefreshTicks);
+ if (now - lastRefresh < RefreshCooldownTicks) return;
Interlocked.Exchange(ref _lastRefreshTicks, now);
- // Phase 1: check .csproj timestamps (cheap — just a few stat calls)
+ // Phase 1: check .csproj timestamps (one stat per project)
if (HasProjectFileChanged())
{
_logger.LogInformation("Project file changed. Full reload needed.");
_compilationCache.Clear();
_cacheAccessOrder.Clear();
await LoadSolutionAsync(_solutionPath, ct);
- return true;
+ return;
}
- // Phase 2: check for new source files (uses EnumerationOptions to skip bin/obj at the OS level)
- if (HasNewSourceFiles())
+ // Phase 2: scan for new source files (expensive directory walk, longer cooldown)
+ var lastStructural = Interlocked.Read(ref _lastStructuralScanTicks);
+ if (now - lastStructural >= StructuralScanCooldownTicks)
{
- _logger.LogInformation("New source files detected. Full reload needed.");
- _compilationCache.Clear();
- _cacheAccessOrder.Clear();
- await LoadSolutionAsync(_solutionPath, ct);
- return true;
+ Interlocked.Exchange(ref _lastStructuralScanTicks, now);
+ if (HasNewSourceFiles())
+ {
+ _logger.LogInformation("New source files detected. Full reload needed.");
+ _compilationCache.Clear();
+ _cacheAccessOrder.Clear();
+ await LoadSolutionAsync(_solutionPath, ct);
+ return;
+ }
}
- // Phase 3: incremental text updates for modified existing documents.
+ // Phase 3: collect changed documents without holding the lock (stat calls are
+ // the expensive part — keeping them lock-free avoids blocking concurrent tool calls).
+ var changed = new List<(DocumentId Id, DocumentInfo Info)>();
+ foreach (var (docId, info) in _knownDocuments)
+ {
+ var currentWriteTime = File.GetLastWriteTimeUtc(info.FilePath);
+ if (currentWriteTime > info.LastWriteUtc)
+ changed.Add((docId, info with { LastWriteUtc = currentWriteTime }));
+ }
+
+ if (changed.Count == 0) return;
+
+ // Apply mutations under lock.
await _writeLock.WaitAsync(ct);
try
{
- var refreshed = false;
-
- foreach (var (docId, lastKnown) in _knownFileTimestamps)
+ foreach (var (docId, info) in changed)
{
- var document = _solution.GetDocument(docId);
- if (document?.FilePath is null) continue;
-
- var currentWriteTime = File.GetLastWriteTimeUtc(document.FilePath);
- if (currentWriteTime <= lastKnown) continue;
-
- var text = await File.ReadAllTextAsync(document.FilePath, ct);
+ var text = await File.ReadAllTextAsync(info.FilePath, ct);
var sourceText = Microsoft.CodeAnalysis.Text.SourceText.From(text);
_solution = _solution.WithDocumentText(docId, sourceText);
- _knownFileTimestamps[docId] = currentWriteTime;
+ _knownDocuments[docId] = info;
- _compilationCache.TryRemove(document.Project.Id, out _);
- _cacheAccessOrder.TryRemove(document.Project.Id, out _);
+ _compilationCache.TryRemove(info.ProjectId, out _);
+ _cacheAccessOrder.TryRemove(info.ProjectId, out _);
- refreshed = true;
- _logger.LogDebug("Refreshed changed document: {Path}", document.FilePath);
+ _logger.LogDebug("Refreshed changed document: {Path}", info.FilePath);
}
-
- return refreshed;
}
finally
{
@@ -369,7 +382,8 @@ private bool HasProjectFileChanged()
{
foreach (var (path, lastKnown) in _projectFileTimestamps)
{
- if (File.Exists(path) && File.GetLastWriteTimeUtc(path) > lastKnown)
+ // GetLastWriteTimeUtc returns year 1601 for missing files — always < lastKnown
+ if (File.GetLastWriteTimeUtc(path) > lastKnown)
return true;
}
return false;
@@ -394,12 +408,15 @@ private bool HasNewSourceFiles()
var projectDir = Path.GetDirectoryName(project.FilePath);
if (projectDir is null || !Directory.Exists(projectDir)) continue;
+ // Pre-compute bin/obj prefixes to avoid per-file Path.GetRelativePath allocation
+ var sep = Path.DirectorySeparatorChar;
+ var binPrefix = $"{projectDir}{sep}bin{sep}";
+ var objPrefix = $"{projectDir}{sep}obj{sep}";
+
foreach (var file in Directory.EnumerateFiles(projectDir, "*.cs", options))
{
- // Skip bin/obj — check cheaply via span to avoid allocation
- var relative = Path.GetRelativePath(projectDir, file);
- if (relative.StartsWith("bin", StringComparison.OrdinalIgnoreCase) ||
- relative.StartsWith("obj", StringComparison.OrdinalIgnoreCase))
+ if (file.StartsWith(binPrefix, StringComparison.OrdinalIgnoreCase) ||
+ file.StartsWith(objPrefix, StringComparison.OrdinalIgnoreCase))
continue;
if (!_knownDocumentPaths.ContainsKey(file))