Close the SettingsClassGenerator concurrency race (T7) - #29
Merged
Conversation
GenerateType did a lock-free TryGetValue and, on a miss, called _moduleBuilder.DefineType(name) then wrote the cache. Two threads racing the same interface both missed the cache and both defined the same type name (the second throws "Duplicate type name" -> the resolve/scan aborts). And because System.Reflection.Emit is not thread-safe, two threads generating different interfaces also race the shared ModuleBuilder's metadata. Q4's ConcurrentDictionary made the cache thread-safe but never serialized the check-then-define. Reachable in production: GenericHost registers ISettingsProvider as a process-wide singleton over one SettingsBuilder, and cache-miss resolves fall through to lazy generation on the shared ModuleBuilder from concurrent DI resolutions. Fix: double-checked locking. The warm cache-hit path stays lock-free (TryGetValue before the lock); on a miss, take a single generation gate, re-check, run the whole emit sequence (extracted to DefineImplementationType) plus the cache write inside the lock, and still wrap failures as TypeGenerationException (uncached). One gate over all generation, matching the shared _moduleBuilder's scope - not a per-type Lazy, which would only serialize same-interface generation and leave the distinct-interface module race open. Tests (+2 in SettingsClassGeneratorTests.cs): a same-interface Parallel.For stress (asserts one shared impl) and a distinct+same Barrier/32-thread stress across 8 interfaces (asserts no failures and exactly one impl per interface; regression-guards the lock-all decision against a future switch to Lazy). Suite 84 net10 (was 82); ran 5x green. Reviewed: plan by dotnet-architect (ENDORSE-WITH-CHANGES - lock-all required, Lazy rejected) + perf/security in-context; code by /code-review plus Roslyn detect_antipatterns (0). Also refreshes FIX-PLAN.md (T7 marked done) and SESSION-HANDOFF.md.
guy-lud
added a commit
that referenced
this pull request
Jul 14, 2026
…ning (#30) * docs(gsd): reconcile .planning with merged T7 (#29); freeze FIX-PLAN.md GSD is now the source of truth for project tracking. Reconcile .planning to the real git state and retire FIX-PLAN.md as the working doc. - Mark ENG-01/T7 complete across REQUIREMENTS/ROADMAP/STATE/PROJECT (shipped pre-GSD via #29 — double-checked locking + same/distinct-interface stress tests) - Clear the stale "T7 race open" concern in STATE; log the generator-serialization decision (one gate over all generation; not Lazy-per-type) - Phase 2 now: ENG-01 done; COLL-01 (C1, deferred) + TEST-01/02/03 remain - Freeze FIX-PLAN.md with a banner pointing at .planning/ (kept for its per-item file:line detail, mined by each phase's CONTEXT/PLAN) Local branch only (no push) — rides the next work branch to avoid a doc-only master alpha. SESSION-HANDOFF.md left uncommitted (living handoff). * docs(02): synthesize phase context from FIX-PLAN (COLL-01 deferred, ENG-01 verify-only) * docs(02): research binding-correctness engine test hardening * docs(02): add validation strategy * docs(02): create Phase 2 binding-correctness test-hardening plans * docs(02): add phase artifacts inventory to plan 01 * docs(02): add pattern map; mark phase planned (2 plans, ready to execute) * test(02-01): add ValuesPopulator precedence + default-survives tests - last-writer-wins across two ordered binders - later silent binder does not clobber earlier set value - [SettingsProperty] DefaultValue survives when no binder sets the property * test(02-01): add TypeConverter null/nullable/ConverterType tests - null for non-nullable int resolves to 0 - Nullable<int> null resolves to null; "42" strips and converts to 42 - ConverterType on IEnumerable<int> bypasses the collection converter (sentinel wins) * docs(02-01): complete engine-core correctness test-hardening plan * test(02-02): add scalar Uri/DateTime conversion coverage (TEST-03) - Scalar Uri positive: bound URL string resolves to new Uri(value) - Scalar DateTime positive: yyyy-MM-dd string resolves via ParseExact - One DateTime format-mismatch negative asserts SettingsPropertyValueException type only - No array-of-* duplication (owned by CollectionConversionTests); no redaction re-proof (ExceptionRedactionTests) * docs(02-02): complete scalar converter-coverage (TEST-03) plan * docs(phase-02): complete phase execution * docs(phase-02): evolve PROJECT.md after phase completion
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
T7 — close the
SettingsClassGeneratorconcurrency raceGenerateTypedid a lock-freeTryGetValueand, on a miss, called_moduleBuilder.DefineType(name)then wrote the cache. Two problems under concurrency:DefineTypethe same name → the second throwsDuplicate type name within an assembly→ the resolve/scan aborts (TypeGenerationException).System.Reflection.Emitis not thread-safe — concurrentDefineTypecalls (even with different names) race the sharedModuleBuilder's metadata/token state.Q4's
ConcurrentDictionarymade the cache thread-safe but never serialized the check-then-define.Reachable in production: GenericHost registers
ISettingsProvideras a process-wide singleton over oneSettingsBuilder; cache-miss resolves fall through to lazy generation on the sharedModuleBuilderfrom concurrent DI resolutions (request threads, hosted services).Fix — double-checked locking
TryGetValuebefore the lock)._generationGate, re-check, run the whole emit sequence (DefineImplementationType: name → extract →DefineType→AddInterfaceImplementation→ properties →CreateTypeInfo) and the cache write inside the lock; failures still wrap asTypeGenerationException(uncached → retryable for pre-DefineTypefailures)._moduleBuilder's scope — deliberately not aLazy<Type>-per-type, which only serializes same-interface generation and would leave the distinct-interface module race open.Tests (+2, suite 84 net10, 5× green)
GenerateType_ConcurrentSameInterface_ReturnsSingleSharedType— 128Parallel.Foriterations on one interface → single shared impl (this one has teeth: pre-fix it very likely hits the duplicate-name throw).GenerateType_ConcurrentAcrossSameAndDistinctInterfaces_IsRaceFree— 32 explicit threads + aBarrieracross 8 interfaces (same- and distinct-interface contention at once) → no failures, exactly one impl per interface. Regression-guards the lock-all decision.Review
dotnet-architect— ENDORSE-WITH-CHANGES: confirmed lock-all is required (Reflection.Emit races the sharedModuleBuilderfor distinct types too), soLazy-per-type was rejected; DCL shape and lock scope verified. Perf/security in-context (off the hot path; no security surface)./code-review(high) + Roslyndetect_antipatterns(0). Build clean, both TFMs.Non-breaking (internal behavior only). Follows #27 (S1) and #28 (C2).