Skip to content

Commit dffd529

Browse files
github-actions[bot]CopilotCopilotsergey-tihon
authored
[Repo Assist] Fix thread-safety races in member-wrapper caches (issue #481) (#482)
🤖 *This is an automated pull request from [Repo Assist](https://github.com/fsprojects/FSharp.TypeProviders.SDK/actions/runs/23358988317).* Fixes #481 — `InvalidOperationException` / `NullReferenceException` when 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`) in `TargetTypeDefinition` so member-wrapper objects are allocated once and reused. When the compiler invokes `GetConstructors`/`GetMethods`/etc. from multiple threads on the same type, several underlying caches that were never designed for concurrent access are hit simultaneously: | Site | Problem | |---|---| | `ILMethodDefs.getmap()` / `ILTypeDefs.getmap()` / `ILExportedTypesAndForwarders.getmap()` | `mutable lmap = null` checked without a lock. Thread A sets `lmap` to a new `Dictionary` and 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) | Same unsynchronised `ref null` / `Dictionary` pattern across all 8 per-reader caches. | | `TxTable(T).Get` | `Dictionary(int,T)` written without any lock; concurrent type-resolution calls (via cached `MethodInfo`/`ConstructorInfo` → `txILTypeRef` → `txTable.Get`) from two threads can collide. | ## Fix * **`ILMethodDefs` / `ILTypeDefs` / `ILExportedTypesAndForwarders`** – add a `syncObj` per instance; build `lmap` inside `lock syncObj` so 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 a `syncObj` and wraps every `TryGetValue` + write pair in `lock`. * **`TxTable(T)`** – backed by `ConcurrentDictionary(int, Lazy<T)>`. `GetOrAdd` races safely; the `Lazy(T)` wrapper (using F#'s default `ExecutionAndPublication` mode) ensures the factory is called **at most once** per token, preserving the identity-equality guarantee that `TxTable` provides. ## Test status New regression test added: **`TargetTypeDefinition member-wrapper caches are thread-safe under parallel access`** — 8 threads × 50 iterations, each calling all six `GetXxx` methods on the same `TargetTypeDefinition` concurrently. ``` Passed! - Failed: 0, Passed: 118, Skipped: 0, Total: 118 (net8.0) ``` All pre-existing tests continue to pass. > Generated by [Repo Assist](https://github.com/fsprojects/FSharp.TypeProviders.SDK/actions/runs/23358988317) for issue #481 · [◷](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: 23358988317, workflow_id: repo-assist, run: https://github.com/fsprojects/FSharp.TypeProviders.SDK/actions/runs/23358988317 --> <!-- 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> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> Co-authored-by: Sergey Tihon <sergey.tihon@gmail.com>
1 parent 821de01 commit dffd529

2 files changed

Lines changed: 81 additions & 55 deletions

File tree

src/ProvidedTypes.fs

Lines changed: 38 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2874,16 +2874,15 @@ module internal AssemblyReader =
28742874

28752875
type ILMethodDefs(larr: Lazy<ILMethodDef[]>) =
28762876

2877-
let mutable lmap = null
2878-
let getmap() =
2879-
if isNull lmap then
2880-
lmap <- Dictionary()
2881-
for y in larr.Force() do
2882-
let key = y.Name
2883-
match lmap.TryGetValue key with
2884-
| true, lmpak -> lmap.[key] <- Array.append [| y |] lmpak
2885-
| false, _ -> lmap.[key] <- [| y |]
2886-
lmap
2877+
let lmap = lazy (
2878+
let m = Dictionary()
2879+
for y in larr.Force() do
2880+
let key = y.Name
2881+
match m.TryGetValue key with
2882+
| true, lmpak -> m.[key] <- Array.append [| y |] lmpak
2883+
| false, _ -> m.[key] <- [| y |]
2884+
m)
2885+
let getmap() = lmap.Value
28872886

28882887
member __.Entries = larr.Force()
28892888
member __.FindByName nm =
@@ -3097,14 +3096,12 @@ module internal AssemblyReader =
30973096

30983097
and ILTypeDefs(larr: Lazy<(string uoption * string * Lazy<ILTypeDef>)[]>) =
30993098

3100-
let mutable lmap = null
3101-
let getmap() =
3102-
if isNull lmap then
3103-
lmap <- Dictionary()
3104-
for (nsp, nm, ltd) in larr.Force() do
3105-
let key = nsp, nm
3106-
lmap.[key] <- ltd
3107-
lmap
3099+
let lmap = lazy (
3100+
let m = Dictionary()
3101+
for (nsp, nm, ltd) in larr.Force() do
3102+
m.[(nsp, nm)] <- ltd
3103+
m)
3104+
let getmap() = lmap.Value
31083105

31093106
member __.Entries =
31103107
[| for (_, _, td) in larr.Force() -> td.Force() |]
@@ -3142,14 +3139,12 @@ module internal AssemblyReader =
31423139
override x.ToString() = "fwd " + x.Name
31433140

31443141
and ILExportedTypesAndForwarders(larr:Lazy<ILExportedTypeOrForwarder[]>) =
3145-
let mutable lmap = null
3146-
let getmap() =
3147-
if isNull lmap then
3148-
lmap <- Dictionary()
3149-
for ltd in larr.Force() do
3150-
let key = ltd.Namespace, ltd.Name
3151-
lmap.[key] <- ltd
3152-
lmap
3142+
let lmap = lazy (
3143+
let m = Dictionary()
3144+
for ltd in larr.Force() do
3145+
m.[(ltd.Namespace, ltd.Name)] <- ltd
3146+
m)
3147+
let getmap() = lmap.Value
31533148
member __.Entries = larr.Force()
31543149
member __.TryFindByName (nsp, nm) = match getmap().TryGetValue ((nsp, nm)) with true, v -> Some v | false, _ -> None
31553150

@@ -4579,34 +4574,25 @@ module internal AssemblyReader =
45794574

45804575
let mkCacheInt32 lowMem _infile _nm _sz =
45814576
if lowMem then (fun f x -> f x) else
4582-
let cache = ref null
4577+
let cache = ConcurrentDictionary<int32, _>()
45834578
fun f (idx:int32) ->
4584-
let cache =
4585-
match !cache with
4586-
| null -> cache := new Dictionary<int32, _>(11)
4587-
| _ -> ()
4588-
!cache
4589-
let mutable res = Unchecked.defaultof<_>
4590-
let ok = cache.TryGetValue(idx, &res)
4591-
if ok then
4592-
res
4593-
else
4594-
let res = f idx
4595-
cache.[idx] <- res;
4596-
res
4579+
match cache.TryGetValue idx with
4580+
| true, v -> v
4581+
| false, _ ->
4582+
let v = f idx
4583+
cache.TryAdd(idx, v) |> ignore
4584+
cache.[idx]
45974585

45984586
let mkCacheGeneric lowMem _inbase _nm _sz =
45994587
if lowMem then (fun f x -> f x) else
4600-
let cache = ref null
4588+
let cache = ConcurrentDictionary<'T, _>()
46014589
fun f (idx :'T) ->
4602-
let cache =
4603-
match !cache with
4604-
| null -> cache := new Dictionary<_, _>(11 (* sz:int *) )
4605-
| _ -> ()
4606-
!cache
46074590
match cache.TryGetValue idx with
4608-
| true, cached -> cached
4609-
| false, _ -> let res = f idx in cache.[idx] <- res; res
4591+
| true, v -> v
4592+
| false, _ ->
4593+
let v = f idx
4594+
cache.TryAdd(idx, v) |> ignore
4595+
cache.[idx]
46104596

46114597
let seekFindRow numRows rowChooser =
46124598
let mutable i = 1
@@ -7000,6 +6986,7 @@ namespace ProviderImplementation.ProvidedTypes
70006986

70016987
open System
70026988
open System.IO
6989+
open System.Collections.Concurrent
70036990
open System.Collections.Generic
70046991
open System.Reflection
70056992
open ProviderImplementation.ProvidedTypes.AssemblyReader
@@ -7012,14 +6999,10 @@ namespace ProviderImplementation.ProvidedTypes
70126999
// Unique wrapped type definition objects must be translated to unique wrapper objects, based
70137000
// on object identity.
70147001
type TxTable<'T2>() =
7015-
let tab = Dictionary<int, 'T2>()
7002+
let tab = ConcurrentDictionary<int, Lazy<'T2>>()
70167003
member __.Get inp f =
7017-
match tab.TryGetValue inp with
7018-
| true, tabVal -> tabVal
7019-
| false, _ ->
7020-
let res = f()
7021-
tab.[inp] <- res
7022-
res
7004+
let lazyVal = tab.GetOrAdd(inp, fun _ -> lazy (f()))
7005+
lazyVal.Value
70237006

70247007
member __.ContainsKey inp = tab.ContainsKey inp
70257008

tests/BasicGenerativeProvisionTests.fs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,3 +511,46 @@ let ``Generative custom attribute with named property argument encodes and round
511511
Assert.Equal(1, namedArgs.Length)
512512
Assert.Equal("Name", namedArgs.[0].MemberName)
513513
Assert.Equal("MyProp", namedArgs.[0].TypedValue.Value :?> string)
514+
515+
[<Fact>]
516+
let ``TargetTypeDefinition member-wrapper caches are thread-safe under parallel access``() =
517+
// Regression test for https://github.com/fsprojects/FSharp.TypeProviders.SDK/issues/481
518+
// PR #471 introduced lazy caches in TargetTypeDefinition. When multiple threads call
519+
// GetConstructors/GetMethods/etc. concurrently on the same generated type the underlying
520+
// shared caches must not corrupt. Run 8 parallel threads each interrogating every member
521+
// kind on the same TargetTypeDefinition; if any internal collection races, the dictionaries
522+
// will throw InvalidOperationException.
523+
let runtimeAssemblyRefs = Targets.DotNetStandard20FSharpRefs()
524+
let runtimeAssembly = runtimeAssemblyRefs.[0]
525+
let cfg = Testing.MakeSimulatedTypeProviderConfig(__SOURCE_DIRECTORY__, runtimeAssembly, runtimeAssemblyRefs)
526+
let staticArgs = [| box 5; box 6 |]
527+
let tp = GenerativePropertyProviderWithStaticParams cfg :> TypeProviderForNamespaces
528+
let providedNamespace = tp.Namespaces.[0]
529+
let providedTypes = providedNamespace.GetTypes()
530+
let providedType = providedTypes.[0]
531+
let typeName = providedType.Name + (staticArgs |> Seq.map (fun s -> ",\"" + s.ToString() + "\"") |> Seq.reduce (+))
532+
let t = (tp :> ITypeProvider).ApplyStaticArguments(providedType, [| typeName |], staticArgs)
533+
let assemContents = (tp :> ITypeProvider).GetGeneratedAssemblyContents(t.Assembly)
534+
let assem = tp.TargetContext.ReadRelatedAssembly(assemContents)
535+
let typeName2 = providedType.Namespace + "." + typeName
536+
let targetType = assem.GetType(typeName2)
537+
Assert.NotNull(targetType)
538+
539+
let bf = BindingFlags.Public ||| BindingFlags.NonPublic ||| BindingFlags.Instance ||| BindingFlags.Static
540+
let errors = System.Collections.Concurrent.ConcurrentBag<exn>()
541+
let threads =
542+
[| for _ in 1..8 ->
543+
System.Threading.Thread(fun () ->
544+
try
545+
for _ in 1..50 do
546+
targetType.GetConstructors(bf) |> ignore
547+
targetType.GetMethods(bf) |> ignore
548+
targetType.GetFields(bf) |> ignore
549+
targetType.GetProperties(bf) |> ignore
550+
targetType.GetEvents(bf) |> ignore
551+
targetType.GetNestedTypes(bf) |> ignore
552+
with ex ->
553+
errors.Add(ex)) |]
554+
for th in threads do th.Start()
555+
for th in threads do th.Join()
556+
Assert.True(errors.IsEmpty, sprintf "Thread-safety violations: %A" (errors |> Seq.toList))

0 commit comments

Comments
 (0)