Skip to content

Close the SettingsClassGenerator concurrency race (T7) - #29

Merged
guy-lud merged 1 commit into
masterfrom
test/t7-generator-concurrency
Jul 14, 2026
Merged

Close the SettingsClassGenerator concurrency race (T7)#29
guy-lud merged 1 commit into
masterfrom
test/t7-generator-concurrency

Conversation

@guy-lud

@guy-lud guy-lud commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

T7 — close the SettingsClassGenerator concurrency race

GenerateType did a lock-free TryGetValue and, on a miss, called _moduleBuilder.DefineType(name) then wrote the cache. Two problems under concurrency:

  1. Same interface: two threads both miss the cache and both DefineType the same name → the second throws Duplicate type name within an assembly → the resolve/scan aborts (TypeGenerationException).
  2. Distinct interfaces: System.Reflection.Emit is not thread-safe — concurrent DefineType calls (even with different names) race the shared ModuleBuilder's metadata/token state.

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; cache-miss resolves fall through to lazy generation on the shared ModuleBuilder from concurrent DI resolutions (request threads, hosted services).

Fix — double-checked locking

  • Warm cache-hit path stays lock-free (TryGetValue before the lock).
  • On a miss: take a single _generationGate, re-check, run the whole emit sequence (DefineImplementationType: name → extract → DefineTypeAddInterfaceImplementation → properties → CreateTypeInfo) and the cache write inside the lock; failures still wrap as TypeGenerationException (uncached → retryable for pre-DefineType failures).
  • One gate over ALL generation, matching the shared _moduleBuilder's scope — deliberately not a Lazy<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 — 128 Parallel.For iterations 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 + a Barrier across 8 interfaces (same- and distinct-interface contention at once) → no failures, exactly one impl per interface. Regression-guards the lock-all decision.

Review

  • Plan reviewed by dotnet-architectENDORSE-WITH-CHANGES: confirmed lock-all is required (Reflection.Emit races the shared ModuleBuilder for distinct types too), so Lazy-per-type was rejected; DCL shape and lock scope verified. Perf/security in-context (off the hot path; no security surface).
  • Code reviewed via /code-review (high) + Roslyn detect_antipatterns (0). Build clean, both TFMs.

Non-breaking (internal behavior only). Follows #27 (S1) and #28 (C2).

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
guy-lud merged commit 10f9275 into master Jul 14, 2026
2 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant