[Repo Assist] Fix thread-safety races in member-wrapper caches (issue #481)#482
Conversation
Closes #481 Root cause: PR #471 added lazy caches (ctorDefs/methDefs/fieldDefs/etc.) in TargetTypeDefinition so wrapper objects are allocated once and shared across all GetConstructors/GetMethods/etc. calls. When the F# compiler invokes these from multiple threads concurrently, the lazies can be forced concurrently, and the underlying non-thread-safe caches race: * ILMethodDefs.getmap() / ILTypeDefs.getmap() / ILExportedTypesAndForwarders.getmap() used a mutable null-check pattern without synchronisation. One thread sets lmap to a new Dictionary and starts filling it; a second thread sees the non-null lmap and reads it while the first is still writing -> InvalidOperationException. * mkCacheInt32 / mkCacheGeneric (binary-reader caches) had the same unsynchronised ref-null pattern. * TxTable<T>.Get wrote to Dictionary<int,T> without a lock; concurrent type-resolution calls (txILTypeRef -> txTable.Get) from shared cached MethodInfo/ConstructorInfo objects could collide. Fixes: * ILMethodDefs / ILTypeDefs / ILExportedTypesAndForwarders: build lmap inside lock syncObj so the dictionary is fully populated before any reader can see it. Subsequent calls acquire the lock, check the already-set field and return immediately (single branch). * mkCacheInt32 / mkCacheGeneric: each cache now holds its own lock object and protects every TryGetValue/set_Item pair. * TxTable<T>: backed by ConcurrentDictionary<int, Lazy<T>> so that concurrent GetOrAdd calls for the same token race safely, with Lazy<T> guaranteeing the factory runs exactly once per token. Adds a thread-safety regression test: 8 threads × 50 iterations each calling GetConstructors/GetMethods/GetFields/GetProperties/GetEvents/ GetNestedTypes on the same TargetTypeDefinition simultaneously. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses thread-safety races in the SDK’s metadata-backed reflection wrappers that can be accessed concurrently by the F# compiler during parallel compilation, preventing intermittent InvalidOperationException/NullReferenceException failures (issue #481).
Changes:
- Adds synchronization to lazy dictionary materialization in
ILMethodDefs,ILTypeDefs, andILExportedTypesAndForwarders. - Makes metadata row caches (
mkCacheInt32/mkCacheGeneric) safe under concurrent access by guarding cache reads/writes. - Reworks
TxTable<'T>to useConcurrentDictionary<int, Lazy<'T>>and adds a regression test exercising concurrent member enumeration on a generated target type.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/ProvidedTypes.fs |
Adds locking / concurrent structures to prevent concurrent mutations of internal caches used during metadata-backed reflection. |
tests/BasicGenerativeProvisionTests.fs |
Adds a regression test that concurrently calls GetConstructors/GetMethods/etc. on the same generated TargetTypeDefinition. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@copilot i don't like this implementation, it is too complicated. rework it using ConcurrentDictionary |
|
@sergey-tihon I've opened a new pull request, #486, to work on those changes. Once the pull request is ready, I'll request review from you. |
`mkCacheInt32` and `mkCacheGeneric` were using `Dictionary` + `lock
syncObj`, serializing all cache access including expensive metadata
decode work.
## Changes
- **`mkCacheInt32` / `mkCacheGeneric`**: replaced `Dictionary` + `lock`
with `ConcurrentDictionary` using `TryGetValue` / `TryAdd`. No locks, no
`syncObj`.
```fsharp
let mkCacheInt32 lowMem _infile _nm _sz =
if lowMem then (fun f x -> f x) else
let cache = ConcurrentDictionary<int32, _>()
fun f (idx:int32) ->
match cache.TryGetValue idx with
| true, v -> v
| false, _ ->
let v = f idx
cache.TryAdd(idx, v) |> ignore
cache.[idx]
```
`GetOrAdd(key, factory)` was not usable here — F# type inference cannot
disambiguate it from `GetOrAdd(key, value)` when the value type is an
unconstrained generic. `TryAdd` has no overloads and sidesteps the
issue. On a concurrent cache miss, `f idx` may be computed twice (both
`GetOrAdd` with factory and this pattern share that behaviour per the
.NET docs), which is acceptable since these factories are pure metadata
reads.
<!-- START COPILOT CODING AGENT TIPS -->
---
⌨️ Start Copilot coding agent tasks without leaving your editor —
available in [VS Code](https://gh.io/cca-vs-code-docs), [Visual
Studio](https://gh.io/cca-visual-studio-docs), [JetBrains
IDEs](https://gh.io/cca-jetbrains-docs) and
[Eclipse](https://gh.io/cca-eclipse-docs).
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com>
…ches The ILMethodDefs, ILTypeDefs and ILExportedTypesAndForwarders caches are build-once-read-many, so a simple lazy value provides thread-safe init without introducing mutable fields, lock objects or ConcurrentDictionary overhead.
|
I hope it ok that i've merged this fix, because master was broken. |
…getTypeDefinition (#485) 🤖 *This is an automated PR from Repo Assist, an AI assistant for this repository.* ## Summary `TargetTypeDefinition.FullName`, `BaseType`, and `GetInterfaces()` each compute their result from immutable input data (`inp.Namespace`/`inp.Name`, `inp.Extends`, `inp.Implements`) but were recomputed on every call — allocating new strings/arrays and re-running type resolution each time. For large type providers with many types (e.g. SwaggerProvider), where the F# compiler queries these properties many times per type during type-checking, this saves repeated allocations and type-resolution work. ### Changes | Property | Before | After | |---|---|---| | `FullName` | String concatenation every call | Cached `lazy` — computed once, same `string` returned thereafter | | `BaseType` | `txILType` (type-resolution) every call | Cached `lazy` — resolved once | | `GetInterfaces()` | `Array.map txILType` (allocates new `Type[]`) every call | Cached `lazy` — resolved and allocated once | All three caches use F# `lazy` which defaults to `LazyThreadSafetyMode.ExecutionAndPublication`, so concurrent first-access from multiple F# compiler threads is safe. This is complementary to PR #471 (which cached member-wrapper arrays) and does not touch the thread-safety areas being addressed by PRs #482/#483. ## Test Status ``` Passed! - Failed: 0, Passed: 117, Skipped: 0, Total: 117 (net8.0) ``` All 117 pre-existing tests pass. The `netstandard2.0` build target ran OOM on the CI machine (infrastructure issue, not caused by this change — the same issue affects master); the `net8.0` build and tests both pass cleanly. > Generated by [Repo Assist](https://github.com/fsprojects/FSharp.TypeProviders.SDK/actions/runs/23367946628) · [◷](https://github.com/search?q=repo%3Afsprojects%2FFSharp.TypeProviders.SDK+%22gh-aw-workflow-id%3A+repo-assist%22&type=pullrequests) > > To install this [agentic workflow](https://github.com/githubnext/agentics/tree/d1d884596e62351dd652ae78465885dd32f0dd7d/workflows/repo-assist.md), run > ``` > gh aw add githubnext/agentics@d1d8845 > ``` <!-- gh-aw-agentic-workflow: Repo Assist, engine: copilot, id: 23367946628, workflow_id: repo-assist, run: https://github.com/fsprojects/FSharp.TypeProviders.SDK/actions/runs/23367946628 --> <!-- gh-aw-workflow-id: repo-assist --> --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🤖 *This PR was created by [Repo Assist](https://github.com/fsprojects/FSharp.TypeProviders.SDK/actions/runs/23774627661), an automated AI assistant.* ## Summary Release notes for version **8.4.0**, covering all changes merged since 8.3.0 (February 26, 2026). ## Changes included in 8.4.0 **Bug fixes:** - `GetEnumUnderlyingType()` now correctly handles non-Int32 enum underlying types (#470) - `decodeILCustomAttribData` reads correct byte-width for non-Int32 enum fixed args (ECMA-335) (#475) - Generative delegate type support fixed; `GetInterface` implemented on `ProvidedTypeDefinition` and `TargetTypeDefinition` (#479) - Thread-safety races in `TargetTypeDefinition` member-wrapper caches fixed (#482) **Performance:** - Member wrapper objects cached in `TargetTypeDefinition` to avoid repeated allocations (#471) - `FullName`, `BaseType` and `GetInterfaces()` cached in `TargetTypeDefinition` (#485) **Refactoring:** - `mkCacheInt32`/`mkCacheGeneric` simplified to use `ConcurrentDictionary` (#486) **CI:** - GitHub Actions updated from v1 to v4 (#476) ## Test Status ✅ All 126 tests pass on the current master (`dotnet test tests/FSharp.TypeProviders.SDK.Tests.fsproj -c Release`) *Please bump the NuGet package version to 8.4.0 in the project file before merging if that step isn't automated.* > Generated by 🌈 Repo Assist at [{run-started}](https://github.com/fsprojects/FSharp.TypeProviders.SDK/actions/runs/23774627661). [Learn more](https://github.com/githubnext/agentics/blob/main/docs/repo-assist.md). > > To install this [agentic workflow](https://github.com/githubnext/agentics/tree/1f672aef974f4246124860fc532f82fe8a93a57e/workflows/repo-assist.md), run > ``` > gh aw add githubnext/agentics@1f672ae > ``` <!-- gh-aw-agentic-workflow: Repo Assist, engine: copilot, model: auto, id: 23774627661, workflow_id: repo-assist, run: https://github.com/fsprojects/FSharp.TypeProviders.SDK/actions/runs/23774627661 --> <!-- gh-aw-workflow-id: repo-assist --> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
🤖 This is an automated pull request from Repo Assist.
Fixes #481 —
InvalidOperationException/NullReferenceExceptionwhen the F# compiler accesses type-provider types from multiple parallel compilation threads.Root cause
PR #471 introduced lazy caches (
ctorDefs/methDefs/fieldDefs/eventDefs/propDefs/nestedDefs) inTargetTypeDefinitionso member-wrapper objects are allocated once and reused. When the compiler invokesGetConstructors/GetMethods/etc. from multiple threads on the same type, several underlying caches that were never designed for concurrent access are hit simultaneously:ILMethodDefs.getmap()/ILTypeDefs.getmap()/ILExportedTypesAndForwarders.getmap()mutable lmap = nullchecked without a lock. Thread A setslmapto a newDictionaryand starts filling it; Thread B sees the non-null value and reads from it while Thread A is writing →InvalidOperationException.mkCacheInt32/mkCacheGeneric(binary-reader row caches)ref null/Dictionarypattern across all 8 per-reader caches.TxTable(T).GetDictionary(int,T)written without any lock; concurrent type-resolution calls (via cachedMethodInfo/ConstructorInfo→txILTypeRef→txTable.Get) from two threads can collide.Fix
ILMethodDefs/ILTypeDefs/ILExportedTypesAndForwarders– add asyncObjper instance; buildlmapinsidelock syncObjso the dictionary is fully populated before any reader sees it. Subsequent calls acquire the lock, see the already-set field, and return immediately.mkCacheInt32/mkCacheGeneric– each closure now owns asyncObjand wraps everyTryGetValue+ write pair inlock.TxTable(T)– backed byConcurrentDictionary(int, Lazy<T)>.GetOrAddraces safely; theLazy(T)wrapper (using F#'s defaultExecutionAndPublicationmode) ensures the factory is called at most once per token, preserving the identity-equality guarantee thatTxTableprovides.Test status
New regression test added:
TargetTypeDefinition member-wrapper caches are thread-safe under parallel access— 8 threads × 50 iterations, each calling all sixGetXxxmethods on the sameTargetTypeDefinitionconcurrently.All pre-existing tests continue to pass.