Skip to content

Commit fad6f39

Browse files
Fix thread-safety races in TargetTypeDefinition member-wrapper caches
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>
1 parent 821de01 commit fad6f39

2 files changed

Lines changed: 94 additions & 53 deletions

File tree

src/ProvidedTypes.fs

Lines changed: 51 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2874,16 +2874,19 @@ module internal AssemblyReader =
28742874

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

2877-
let mutable lmap = null
2877+
let mutable lmap : Dictionary<string, ILMethodDef[]> = null
2878+
let syncObj = obj()
28782879
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
2880+
lock syncObj (fun () ->
2881+
if isNull lmap then
2882+
let m = Dictionary()
2883+
for y in larr.Force() do
2884+
let key = y.Name
2885+
match m.TryGetValue key with
2886+
| true, lmpak -> m.[key] <- Array.append [| y |] lmpak
2887+
| false, _ -> m.[key] <- [| y |]
2888+
lmap <- m
2889+
lmap)
28872890

28882891
member __.Entries = larr.Force()
28892892
member __.FindByName nm =
@@ -3097,14 +3100,16 @@ module internal AssemblyReader =
30973100

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

3100-
let mutable lmap = null
3103+
let mutable lmap : Dictionary<string uoption * string, Lazy<ILTypeDef>> = null
3104+
let syncObj = obj()
31013105
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
3106+
lock syncObj (fun () ->
3107+
if isNull lmap then
3108+
let m = Dictionary()
3109+
for (nsp, nm, ltd) in larr.Force() do
3110+
m.[(nsp, nm)] <- ltd
3111+
lmap <- m
3112+
lmap)
31083113

31093114
member __.Entries =
31103115
[| for (_, _, td) in larr.Force() -> td.Force() |]
@@ -3142,14 +3147,16 @@ module internal AssemblyReader =
31423147
override x.ToString() = "fwd " + x.Name
31433148

31443149
and ILExportedTypesAndForwarders(larr:Lazy<ILExportedTypeOrForwarder[]>) =
3145-
let mutable lmap = null
3150+
let mutable lmap : Dictionary<string uoption * string, ILExportedTypeOrForwarder> = null
3151+
let syncObj = obj()
31463152
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
3153+
lock syncObj (fun () ->
3154+
if isNull lmap then
3155+
let m = Dictionary()
3156+
for ltd in larr.Force() do
3157+
m.[(ltd.Namespace, ltd.Name)] <- ltd
3158+
lmap <- m
3159+
lmap)
31533160
member __.Entries = larr.Force()
31543161
member __.TryFindByName (nsp, nm) = match getmap().TryGetValue ((nsp, nm)) with true, v -> Some v | false, _ -> None
31553162

@@ -4579,34 +4586,29 @@ module internal AssemblyReader =
45794586

45804587
let mkCacheInt32 lowMem _infile _nm _sz =
45814588
if lowMem then (fun f x -> f x) else
4582-
let cache = ref null
4589+
let cache = Dictionary<int32, _>()
4590+
let syncObj = obj()
45834591
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
4592+
lock syncObj (fun () ->
4593+
let mutable res = Unchecked.defaultof<_>
4594+
if cache.TryGetValue(idx, &res) then res
4595+
else
4596+
let v = f idx
4597+
cache.[idx] <- v
4598+
v)
45974599

45984600
let mkCacheGeneric lowMem _inbase _nm _sz =
45994601
if lowMem then (fun f x -> f x) else
4600-
let cache = ref null
4602+
let cache = Dictionary<'T, _>()
4603+
let syncObj = obj()
46014604
fun f (idx :'T) ->
4602-
let cache =
4603-
match !cache with
4604-
| null -> cache := new Dictionary<_, _>(11 (* sz:int *) )
4605-
| _ -> ()
4606-
!cache
4607-
match cache.TryGetValue idx with
4608-
| true, cached -> cached
4609-
| false, _ -> let res = f idx in cache.[idx] <- res; res
4605+
lock syncObj (fun () ->
4606+
match cache.TryGetValue idx with
4607+
| true, cached -> cached
4608+
| false, _ ->
4609+
let v = f idx
4610+
cache.[idx] <- v
4611+
v)
46104612

46114613
let seekFindRow numRows rowChooser =
46124614
let mutable i = 1
@@ -7012,14 +7014,10 @@ namespace ProviderImplementation.ProvidedTypes
70127014
// Unique wrapped type definition objects must be translated to unique wrapper objects, based
70137015
// on object identity.
70147016
type TxTable<'T2>() =
7015-
let tab = Dictionary<int, 'T2>()
7017+
let tab = System.Collections.Concurrent.ConcurrentDictionary<int, Lazy<'T2>>()
70167018
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
7019+
let lazyVal = tab.GetOrAdd(inp, fun _ -> lazy (f()))
7020+
lazyVal.Value
70237021

70247022
member __.ContainsKey inp = tab.ContainsKey inp
70257023

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)