Skip to content

cwm-roslyn-navigator permanently locks analyzer/source-generator DLLs, breaking dotnet restore on package upgrades #25

Description

@jamesburton

Summary

cwm-roslyn-navigator holds permanent file locks on analyzer / source-generator DLLs in the NuGet global packages folder (~/.nuget/packages/**/analyzers/**) for the entire lifetime of the MCP server process. On Windows this makes dotnet restore fail with Access to the path '<Analyzer>.dll' is denied whenever a package version change requires NuGet to rewrite an already-extracted analyzer assembly.

The locks are never released while the server runs — not after the tool call completes, not on idle. The only remedy today is killing the process.

This is a downstream manifestation of the still-open Roslyn bug dotnet/roslyn#78196, amplified by the navigator's compilation cache.

Impact

Day to day this is invisible, because a normal build never rewrites an extracted analyzer. It bites specifically on package upgrades — exactly when a developer is most likely to be leaning on the navigator to understand the blast radius.

Observed on a real solution while bumping an internal package set. Three consecutive dotnet restore attempts each failed on a different analyzer DLL, because each attempt got a little further before hitting the next locked file:

error : Access to the path 'Microsoft.AspNetCore.OpenApi.SourceGenerators.dll' is denied.
error : Access to the path 'Riok.Mapperly.Abstractions.dll' is denied.
error : Access to the path 'System.ClientModel.SourceGeneration.dll' is denied.

Confirmed the lock and the holder directly:

$f = "$env:USERPROFILE\.nuget\packages\microsoft.aspnetcore.openapi\10.0.10\analyzers\dotnet\cs\Microsoft.AspNetCore.OpenApi.SourceGenerators.dll"
[System.IO.File]::Open($f,'Open','ReadWrite','None')
# The process cannot access the file ... because it is being used by another process.

Get-Process | Where-Object { $_.Modules.FileName -like "*OpenApi.SourceGenerators*" } | Select Name,Id
# cwm-roslyn-navigator  x9

Killing the processes released every handle immediately and dotnet restore then succeeded first time.

Two things ruled out during diagnosis, worth recording so others don't repeat them:

  • dotnet build-server shutdown does not help. It clears MSBuild / VBCS nodes only; the navigator is a separate process. Our first retry still failed, just on a different DLL.
  • It is not antivirus. Windows Defender real-time protection was off (Get-MpComputerStatusRealTimeProtectionEnabled: False), and writing a file with the identical name into a fresh directory succeeded. The denial is specifically on overwriting a file the navigator has mapped.

Root cause

dotnet/roslyn#78196"Roslyn locks and doesn't unlock AnalyzerReference files after calling GetCompilationAsync". Roslyn loads analyzer/generator assemblies during compilation and never releases the handles on Windows. Opened April 2025, still open, untriaged, no maintainer response, no milestone. Reported against 4.13.0; this repro is on Microsoft.CodeAnalysis 5.0.0, so it is still present there.

Since that issue shows no sign of movement, the practical fix belongs downstream — as it did for OmniSharp.

The navigator currently meets every precondition for the bug (v0.7.0, mcp/CWM.RoslynNavigator):

Precondition Where
Default analyzer assembly loader — no shadow copy src/WorkspaceManager.cs:74MSBuildWorkspace.Create(). No ShadowCopy / AnalyzerAssemblyLoader anywhere in src/.
Calls the exact trigger GetCompilationAsync15 call sites across SymbolResolver.cs, WorkspaceManager.cs, GetDiagnosticsTool, FindDeadCodeTool, GetTestCoverageMapTool, DetectAntiPatternsTool, DetectCircularDependenciesTool, GetDependencyGraphTool
Deliberately retains compilations src/WorkspaceManager.cs:21 MaxCachedCompilations = 30; :25 ConcurrentDictionary<ProjectId, Compilation> _compilationCache
Long-lived host MCP server runs for the whole editor session

The cache is the amplifier: even after a tool call returns, up to 30 Compilation objects — and therefore their AnalyzerReference assemblies — are held deliberately. Dispose() (:430-433) and the workspace reload path (:71-72) are the only things that let go, and neither runs during normal operation.

Secondary problem: process accumulation

There were 9 cwm-roslyn-navigator processes alive simultaneously on one machine, each holding its own handle set. Even a perfect per-process fix leaves orphans locking files if instances accumulate across sessions/worktrees. Worth treating as a separate defect.

Suggested fix

Primary: shadow-copy the analyzer assemblies

This is the proven remedy and it is what OmniSharp adopted for the identical symptom — OmniSharp/omnisharp-roslyn#1465 (closed by fix), see also PR #2236 "Reuse Roslyn's analyzer assembly loader".

Roslyn ships ShadowCopyAnalyzerAssemblyLoader precisely for hosts that must not lock the originals. Loading analyzers from a shadow copy means the files in ~/.nuget/packages are never mapped, so NuGet can always rewrite them.

This fixes the root cause with no behavioural trade-off — no lost cache, no dropped warm state, no surprise restarts. Everything below is a mitigation for the case where this proves impractical.

Requested discussion: lifetime strategy

Filing this partly to ask which lifetime model you want, because the mitigations differ in how much they disturb expected long-lived-host usage (VS-style warm caching, where dropping the workspace on a timer would be a regression):

a) Terminate on completion — dispose the workspace after each tool call. Guarantees no lingering locks, but destroys the compilation cache and makes every call a cold load. On a large solution that is a severe latency regression and would defeat the purpose of MaxCachedCompilations. Not recommended as a default.

b) Configurable idle timeout — release the workspace (and its analyzer handles) after N seconds of inactivity, reload lazily on next use. Keeps the cache warm during active work and frees locks while the developer is at a terminal running restore/build — which is exactly when the conflict occurs. Reasonable default in the 60–300s range, opt-out via 0/disabled for users who prefer permanent warmth. This is the best behaviour-preserving mitigation if shadow copy cannot be adopted.

c) General/global termination — a blunt shutdown of the server. Effectively what users do by hand today. Fine as an explicit escape hatch, wrong as automatic policy.

d) Explicit release tool — an MCP tool (e.g. release_workspace / unlock) letting the agent drop handles on demand before a restore, then reload. Cheap to implement, composes well with (b), and keeps the decision with the caller rather than a timer.

Suggested combination: (a-primary) shadow copy as the actual fix, plus (d) as an immediate escape hatch, and (b) as a configurable safety net — with all timer-based behaviour off by default so VS-style long-lived usage is unaffected unless opted into.

Environment

Plugin dotnet-claude-kit 0.7.0, mcp/CWM.RoslynNavigator
TFM net10.0
Roslyn Microsoft.CodeAnalysis.Workspaces.MSBuild 5.0.0, Microsoft.CodeAnalysis.CSharp.Workspaces 5.0.0
MSBuild locator Microsoft.Build.Locator 1.7.8
SDK .NET SDK 10.0.302
OS Windows 11 Pro 26200
Defender RTP Disabled (ruled out as a cause)

Repro

  1. Open a multi-project solution so the navigator loads the workspace.
  2. Run any tool that reaches GetCompilationAsync (e.g. get_diagnostics, find_dead_code, detect_antipatterns).
  3. Change a PackageReference version such that a transitive analyzer/source-generator package resolves to a different version.
  4. dotnet restore.

Expected: restore succeeds.
Actual: error : Access to the path '<Analyzer>.dll' is denied. Repeats on a different analyzer each attempt until the navigator process is killed.

References

Current workaround

Get-Process -Name cwm-roslyn-navigator -ErrorAction SilentlyContinue | Stop-Process -Force
dotnet restore
# then reconnect the MCP server (/mcp in Claude Code)

Note dotnet build-server shutdown is not sufficient — it does not touch the navigator process.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions