Skip to content

Make CapturingDiagnosticsLogger safe for concurrent writers - #20553

Draft
xperiandri wants to merge 2 commits into
dotnet:mainfrom
xperiandri:fix/capturing-logger-thread-safety
Draft

xperiandri wants to merge 2 commits into
dotnet:mainfrom
xperiandri:fix/capturing-logger-thread-safety

Conversation

@xperiandri

Copy link
Copy Markdown
Contributor

Description

CapturingDiagnosticsLogger keeps its diagnostics in an unsynchronized ResizeArray, but the transparent compiler writes into one instance from several threads at once. Two racing Add calls can leave the list with a Count larger than its backing array. From then on every CommitDelayedDiagnostics throws from ToArray():

System.ArgumentException: Source array was not long enough. Check srcIndex and length, and the array's lower bounds.
   at System.Collections.Generic.List`1.ToArray()
   at FSharp.Compiler.DiagnosticsLogger.CapturingDiagnosticsLogger.CommitDelayedDiagnostics
   at FSharp.Compiler.AsyncMemoize ... Get

The broken list belongs to a completed AsyncMemoize job, so the job stays cached and every later request for that result rethrows. In Visual Studio this shows up as a continuous stream of first-chance exceptions from the diagnostic analyzers and a sluggish UI while debugging. In the debugger, the logger of the failing ParseAndCheckFileInProject job had _size = 18 against _items.Length = 16.

Here is one path that writes into a single logger from several threads at once:

  • AsyncMemoize.Get installs a fresh CapturingDiagnosticsLogger "cache" as the ambient logger for the job's computation.
  • ComputeTcLastFile runs processTypeCheckingGraph inside that computation. processGraphAsync starts every graph node with Async.Start, so each node inherits that "cache" logger through the AsyncLocal.
  • Each node calls ComputeTcIntermediate, itself an AsyncMemoize.Get. When that call finishes, it runs CommitDelayedDiagnostics DiagnosticsThreadStatics.DiagnosticsLogger on the node's thread and so writes into the shared parent logger at the same time as its sibling nodes.

MultipleDiagnosticsLoggers.Parallel avoids this by giving each computation its own logger, but graph processing does not use it. Every such path ends up in the logger, so the fix makes the logger itself safe for concurrent use:

  • DiagnosticSink adds the diagnostic and updates the error count under a lock.
  • Diagnostics and CommitDelayedDiagnostics copy the list under the same lock. CommitDelayedDiagnostics then replays the copy outside the lock, so a sink that reports back into this logger still works and no other logger's lock is taken while this one is held.

Order across concurrent writers is still whatever order they arrive in, as before. The change stops diagnostics from being lost and the list from being corrupted.

Covered cases

  • Kept: 100,000 errorR calls from Parallel.For into one CapturingDiagnosticsLogger produce 100,000 entries in ErrorCount, Diagnostics and the committed target. Without the lock the test fails.

Checklist

  • Test cases added
  • Performance benchmarks added in case of performance changes — not applicable.
  • Release notes entry updated

🤖 Generated with Claude Code

Graph type-checking in the transparent compiler starts each node with
Async.Start, so every node inherits the parent AsyncMemoize job's
"cache" logger and commits its own delayed diagnostics into it from its
own thread. Racing ResizeArray.Add calls can leave Count above the
backing array's length, and every later ToArray() in
CommitDelayedDiagnostics then throws ArgumentException ("Source array
was not long enough"), which poisons the cached job.

Add under a lock, and snapshot under the same lock for Diagnostics and
CommitDelayedDiagnostics; the replay stays outside the lock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

❗ Release notes required

You can open this PR in browser to add release notes: open in github.dev


✅ Found changes and release notes in following paths:

Change path Release notes path Description
`src/Compiler` docs/release-notes/.FSharp.Compiler.Service/11.0.100.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

1 participant