dotnet watch crashes with "An item with the same key has already been added" when a referenced project uses Nerdbank.GitVersioning
- Dominant language
- C#
- Stars
- 3.2k
- Forks
- 1.3k
- PR merge metrics
- PR metrics pending
Description
`dotnet watch` fails immediately on startup with an unhandled `System.ArgumentException` whenever the watched project has a `ProjectReference` to another project, and both projects pull in [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) (NBGV) via `PackageReference`. The crash happens during project-graph evaluation, before any file-watching begins — `dotnet watch` is unusable for the project.
The error is a duplicate-key collision on NBGV's internal `PrivateP2PCaching.proj` helper, which apparently gets added to the project graph once per `ProjectReference` edge but keyed only by `(ProjectPath, TargetFramework)` — and its `TargetFramework` is always empty, so two or more edges into it collide.
This reproduces with a completely vanilla two-project solution (no central package management, no Aspire, no third-party watch tooling) — see minimal repro below.
**This looks like a `dotnet watch`-specific bug, not an NBGV or general MSBuild static-graph issue.** Running `dotnet build --graph` against the exact same two projects (which loads the same kind of project graph via `Microsoft.Build.Graph.ProjectGraph`) succeeds cleanly:
```
Static graph construction started.
Static graph loaded in 0.161 seconds: 4 nodes, 4 edges
Build succeeded.
```
So the shared MSBuild graph infrastructure correctly deduplicates `PrivateP2PCaching.proj` into a single node even though it's reachable via two edges. The crash only happens in `dotnet watch`'s own separate graph-loading code (`Microsoft.DotNet.Watch.EvaluationResult`/`LoadedProjectGraph` in the stack trace below — types owned by `dotnet watch`, not NBGV or MSBuild core), which apparently doesn't dedupe the same case before building its `ProjectInstanceId`-keyed dictionary.
### Exception
```
dotnet watch ❌ An unexpected error occurred: System.ArgumentException: An item with the same key has already been added. Key: ProjectInstanceId { ProjectPath = C:\Users\alex\.nuget\packages\nerdbank.gitversioning\3.10.91\build\PrivateP2PCaching.proj, TargetFramework = }
at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
at System.Collections.Generic.Dictionary`2.Add(TKey key, TValue value)
at System.Linq.Enumerable.SpanToDictionary[TSource,TKey,TElement](ReadOnlySpan`1 source, Func`2 keySelector, Func`2 elementSelector, IEqualityComparer`1 comparer)
at System.Linq.Enumerable.ToDictionary[TSource,TKey,TElement](IEnumerable`1 source, Func`2 keySelector, Func`2 elementSelector, IEqualityComparer`1 comparer)
at System.Linq.Enumerable.ToDictionary[TSource,TKey,TElement](IEnumerable`1 source, Func`2 keySelector, Func`2 elementSelector)
at Microsoft.DotNet.Watch.EvaluationResult.TryCreateAsync(LoadedProjectGraph projectGraph, ILogger logger, GlobalOptions globalOptions, EnvironmentOptions environmentOptions, String mainProjectTargetFramework, Boolean restore, CancellationToken cancellationToken)
at Microsoft.DotNet.Watch.HotReloadDotNetWatcher.WatchAsync(CancellationToken shutdownCancellationToken)
at Microsoft.DotNet.Watch.HotReloadDotNetWatcher.WatchAsync(CancellationToken shutdownCancellationToken)
at Microsoft.DotNet.Watch.Program.RunAsync()
```
### `dotnet watch --verbose` output
Per [the request on #45455](https://github.com/dotnet/sdk/issues/45455#issuecomment-3081631405), I reproduced with `dotnet watch --verbose` and also set `Microsoft_CodeAnalysis_EditAndContinue_LogDir` first. The log directory stayed empty — makes sense in hindsight, since that variable controls Roslyn's Edit-and-Continue session logging, which only activates once a hot-reload session is established against a running process, and this crash happens during project-graph construction, before the app ever runs.
The verbose output does show something the plain output didn't: there are two separate project-graph loads — one during the `dotnet build` invocation that `dotnet watch` runs first (succeeds, "Project graph loaded in 0.3s"), and a second, `dotnet watch`-specific one ("Loading projects ...") where the crash actually happens:
```
dotnet watch ⌚ Working directory: '...\dotnet-watch-nbgv-repro\Watched'
dotnet watch ⌚ Watching with Hot Reload.
dotnet watch 🔥 Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.
dotnet watch 💡 Press Ctrl+R to restart.
dotnet watch 🔨 Building ...\dotnet-watch-nbgv-repro\Watched\Watched.csproj ...
dotnet watch 🚀 Launched 'C:\Program Files\dotnet\dotnet.exe' with arguments 'build ...\Watched\Watched.csproj --property:NuGetInteractive=false': process id 15928
Determining projects to restore...
Restored ...\dotnet-watch-nbgv-repro\Watched\Watched.csproj (in 167 ms).
Restored ...\dotnet-watch-nbgv-repro\LibA\LibA.csproj (in 167 ms).
LibA -> ...\dotnet-watch-nbgv-repro\LibA\bin\Debug\net10.0\LibA.dll
Watched -> ...\dotnet-watch-nbgv-repro\Watched\bin\Debug\net10.0\Watched.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:01.25
dotnet watch ⌚ Process id 15928 ran for 1441ms and exited with exit code 0.
dotnet watch 🔨 Project graph loaded in 0.3s.
dotnet watch ⌚ Loading projects ...
dotnet watch ⌚ Terminating remaining child processes.
dotnet watch ❌ An unexpected error occurred: System.ArgumentException: An item with the same key has already been added. Key: ProjectInstanceId { ProjectPath = C:\Users\alex\.nuget\packages\nerdbank.gitversioning\3.10.91\build\PrivateP2PCaching.proj, TargetFramework = }
at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
at System.Collections.Generic.Dictionary`2.Add(TKey key, TValue value)
at System.Linq.Enumerable.SpanToDictionary[TSource,TKey,TElement](ReadOnlySpan`1 source, Func`2 keySelector, Func`2 elementSelector, IEqualityComparer`1 comparer)
at System.Linq.Enumerable.ToDictionary[TSource,TKey,TElement](IEnumerable`1 source, Func`2 keySelector, Func`2 elementSelector, IEqualityComparer`1 comparer)
at System.Linq.Enumerable.ToDictionary[TSource,TKey,TElement](IEnumerable`1 source, Func`2 keySelector, Func`2 elementSelector)
at Microsoft.DotNet.Watch.EvaluationResult.TryCreateAsync(LoadedProjectGraph projectGraph, ILogger logger, GlobalOptions globalOptions, EnvironmentOptions environmentOptions, String mainProjectTargetFramework, Boolean restore, CancellationToken cancellationToken)
at Microsoft.DotNet.Watch.HotReloadDotNetWatcher.WatchAsync(CancellationToken shutdownCancellationToken)
at Microsoft.DotNet.Watch.HotReloadDotNetWatcher.WatchAsync(CancellationToken shutdownCancellationToken)
at Microsoft.DotNet.Watch.Program.RunAsync()
```
That "Project graph loaded in 0.3s" (succeeds) immediately followed by a second "Loading projects ..." (crashes) matches the theory above: the build's own graph load is fine, and it's `dotnet watch`'s own separate graph-loading pass (`EvaluationResult.TryCreateAsync`/`LoadedProjectGraph`) that fails to dedupe the shared `PrivateP2PCaching.proj` node.
### Minimal repro
Two projects, `Watched` (an exe) with a `ProjectReference` to `LibA` (a classlib), both referencing `Nerdbank.GitVersioning`:
**Watched/Watched.csproj**
```xml
runtime; build; native; contentfiles; analyzers; buildtransitive
all
Exe
net10.0
enable
enable
```
**LibA/LibA.csproj**
```xml
net10.0
enable
enable
runtime; build; native; contentfiles; analyzers; buildtransitive
all
```
`Watched/Program.cs` and `LibA/Class1.cs` are unmodified `dotnet new console`/`dotnet new classlib` output.
**version.json** (repo root — NBGV requires this and a git repository)
```json
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "1.0"
}
```
**global.json** (repo root, pins the SDK used to reproduce)
```json
{
"sdk": {
"version": "10.0.400"
}
}
```
Steps:
```bash
git init
git add -A
git commit -m "initial"
cd Watched
dotnet watch
```
Result: crashes immediately with the exception above, before "Waiting for changes" is ever reached.
### Isolation notes
While minimizing, I confirmed:
- **A `ProjectReference` is required.** `Watched` alone (no reference to `LibA`, NBGV still referenced) watches fine — "Loaded 2 project(s)" and reaches "Waiting for changes" normally.
- **Both ends of the reference need to reference NBGV.** If only `Watched` (the watched/entry project) references `Nerdbank.GitVersioning`, or only `LibA` (the referenced project) does, it watches fine in either case — "Loaded 3 project(s)" and reaches "Waiting for changes" normally. The crash only appears once **both** projects reference the package, which is what the repro above does.
- **Central Package Management is not required.** The above repro uses plain per-project `PackageReference`; I originally hit this with `Directory.Packages.props` + `GlobalPackageReference` (in a real multi-project repo with three projects: an AppHost referencing two other projects), and it reproduces identically either way, and with just one `ProjectReference` edge rather than two.
- **Aspire is not involved.** I originally hit this via `aspire run` (which shells out to `dotnet watch` on the AppHost project), but the crash reproduces with plain `dotnet watch` and no Aspire packages at all.
- **`dotnet build`/`dotnet run` are unaffected, including with `--graph`.** Only `dotnet watch` crashes. `dotnet build --graph` on the same two projects succeeds and reports a clean 4-node graph (see above), which is why I think this is specifically a bug in `dotnet watch`'s own project-graph handling rather than in NBGV's build-time technique or in MSBuild's static graph feature generally.
- Removing the `ProjectReference`, removing NBGV from one side of it, or setting the MSBuild property `NBGV_CacheMode=None` (which disables NBGV's `PrivateP2PCaching.proj` mechanism entirely — see [`Nerdbank.GitVersioning.props`](https://github.com/dotnet/Nerdbank.GitVersioning/blob/main/src/Nerdbank.GitVersioning.Tasks/build/Nerdbank.GitVersioning.props)), all avoid the crash.
### Versions
```
.NET SDK:
Version: 10.0.400
Commit: 14fbf8d527
MSBuild version: 18.9.6+14fbf8d52
Runtime Environment:
OS Name: Windows
OS Version: 10.0.26200
OS Platform: Windows
RID: win-x64
```
Nerdbank.GitVersioning: 3.10.91
### Expected behavior
`dotnet watch` starts normally and watches both projects.
### Actual behavior
`dotnet watch` crashes with an unhandled `System.ArgumentException` before watching begins.
### Additional context
This looks like the same class of issue as #45455 and #59018 ("An item with the same key has already been added"), but with a different, reliably-reproducible trigger and a distinct stack trace (`EvaluationResult.TryCreateAsync` rather than `IncrementalMSBuildWorkspace.UpdateProjectConeAsync`). Filing separately since I have a minimal, deterministic repro; happy to have it merged/closed as a duplicate if it's actually the same root cause.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with Microsoft.DotNet.Watch.EvaluationResult.TryCreateAsync and LoadedProjectGraph, using the two-project NBGV reproduction described in the issue to observe the duplicate ProjectInstanceId. Trace the dotnet watch project-graph loading pass and verify that the repro reaches the normal watching state without the duplicate-key exception.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- build-system, cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100