Skip to content

[Repo Assist] Fix thread-safety races in TargetTypeDefinition member-wrapper caches#483

Closed
github-actions[bot] wants to merge 2 commits intomasterfrom
repo-assist/fix-thread-safety-lazy-caches-481-9ed88c14244a34af
Closed

[Repo Assist] Fix thread-safety races in TargetTypeDefinition member-wrapper caches#483
github-actions[bot] wants to merge 2 commits intomasterfrom
repo-assist/fix-thread-safety-lazy-caches-481-9ed88c14244a34af

Conversation

@github-actions
Copy link
Copy Markdown
Contributor

🤖 This PR was created by Repo Assist, an automated AI assistant.

Closes #481

Root Cause

PR #471 introduced lazy caches (ctorDefs, methDefs, etc.) in TargetTypeDefinition so that wrapper objects (ConstructorInfo, MethodInfo, …) are created once and shared across callers. This exposed pre-existing but latent race conditions in the downstream data structures, because the same wrapper objects are now accessed from multiple F# compiler threads simultaneously.

There were three independent races:

Location Race mechanism
ILMethodDefs.getmap() / ILTypeDefs.getmap() / ILExportedTypesAndForwarders.getmap() Thread A sets lmap <- Dictionary() and starts filling it; Thread B sees non-null lmap and calls TryGetValue while A is still inserting → InvalidOperationException: Operations that change non-concurrent collections must have exclusive access
mkCacheInt32 / mkCacheGeneric (binary-reader caches) Same ref null + unsynchronised Dictionary pattern; two threads both see null, both allocate a new Dictionary, one overwrites the other → later readers get a stale/empty cache or a partially-filled one
TxTable(T).Get Plain Dictionary(int,'T2) with no locking; concurrent txILType calls sharing the same TargetTypeDefinition wrapper race on dict[inp] <- resultInvalidOperationException or NullReferenceException

The reported InvalidOperationException: Operations that change non-concurrent collections must have exclusive access (Ubuntu) and NullReferenceException (Windows) are both manifestations of these races.

Fix

ILMethodDefs.getmap() / ILTypeDefs.getmap() / ILExportedTypesAndForwarders.getmap(): Added a syncObj = obj() per instance; getmap() builds the map into a local value under lock syncObj and assigns lmap <- m only after construction is complete.

mkCacheInt32 / mkCacheGeneric: Replaced the ref null + unsynchronised Dictionary pattern with an eagerly-allocated Dictionary + syncObj; all lookups and writes are wrapped in lock syncObj.

TxTable(T).Get: Replaced Dictionary(int,'T2) with a ConcurrentDictionary(int, Lazy<'T2)>; GetOrAdd is race-safe, and wrapping the value in Lazy (default ExecutionAndPublication mode) ensures the factory runs exactly once per token even under concurrent access. This preserves the identity guarantee: same type token → same TargetTypeDefinition object.

Test Status

Added regression test: TargetTypeDefinition member-wrapper caches are thread-safe under parallel access — 8 threads × 50 iterations, all six GetXxx methods called concurrently.

Passed!  - Failed: 0, Passed: 118, Skipped: 0, Total: 118

All 118 tests pass (117 pre-existing + 1 new).


Generated by Repo Assist

Generated by Repo Assist for issue #481 ·

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/repo-assist.md@d1d884596e62351dd652ae78465885dd32f0dd7d

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>
@sergey-tihon
Copy link
Copy Markdown
Member

Duplicate of #482

@sergey-tihon sergey-tihon marked this as a duplicate of #482 Mar 22, 2026
@sergey-tihon sergey-tihon deleted the repo-assist/fix-thread-safety-lazy-caches-481-9ed88c14244a34af branch March 22, 2026 06:11
dsyme pushed a commit that referenced this pull request Mar 23, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ILTypeBuilder lazy member-wrapper caches may race under parallel F# compilation

1 participant