Skip to content

Concurrent interface GUID caching can cause sporadic ComWrappers marshalling failures #134231

Description

@davidwrighton

CoreCLR lazily caches an interface's GuidInfo by initializing a loader-heap entry and publishing its pointer with
an ordinary store. A concurrent cache reader can observe the pointer without acquiring the payload initialization.
The verified in-process reader is limited to supported Windows COM marshalling after
ComWrappers.RegisterForMarshalling; it is not a general Type.GUID read failure.

This report is source-analysis-backed and was not dynamically reproduced. The clearest expected symptom is a
sporadic marshalling exception: LoaderHeap returns zero-initialized memory, so an early read of an explicitly
attributed interface entry obtains GUID_NULL or a partially initialized IID, and QueryInterface normally
returns E_NOINTERFACE. The source does not establish memory corruption or use-after-free. Windows Arm64 is the
most relevant supported target because its weak ordering can expose the missing publication edge directly.

Evidence

  • src\coreclr\vm\class.h:1443-1455 - GetGuidInfo and SetGuidInfo perform plain pointer accesses with no
    acquire/release operation.
  • src\coreclr\vm\methodtable.cpp:6169-6191,6276-6305 - the non-classic interface path consumes the cached GUID
    and classification, while the writer initializes those fields and plain-stores the pointer.
  • src\coreclr\vm\interopconverter.cpp:12-30,100-108 - the Windows COM path uses the non-classic cache value as
    the IID for SafeQueryInterface only after the global ComWrappers bridge returns a wrapper.
  • src\coreclr\System.Private.CoreLib\src\System\Runtime\InteropServices\ComWrappers.CoreCLR.cs:83-96 and
    src\libraries\System.Private.CoreLib\src\System\Runtime\InteropServices\ComWrappers.cs:1644-1652 - without a
    registered global marshalling instance the bridge returns no wrapper; RegisterForMarshalling supplies it.
  • src\coreclr\utilcode\loaderheap.cpp:419-423,919-931 - loader-heap allocations have a zero-initialization
    invariant, including explicit clearing of reused free-list blocks.

Feasible interleaving

  1. After global ComWrappers registration, thread A performs the first GUID lookup for an already loaded,
    nongeneric, COM-visible interface with an explicit nonzero GUID. It receives a zero-initialized GuidInfo,
    writes the GUID and classification, and plain-stores the cache pointer.
  2. Thread B concurrently marshals an object as that interface. Its global wrapper creation completes, and its
    non-classic GetGuid load observes A's non-null pointer without an acquire operation.
  3. B observes GUID_NULL or a partially visible GUID instead of the completely initialized explicit IID.
  4. B passes that IID to SafeQueryInterface; the source-derived expected Release consequence is a spurious
    E_NOINTERFACE and managed marshalling exception. No dynamic failure was reproduced in this investigation.

Expected behavior

Observing a non-null m_pGuidInfo must also make every preceding write to that immutable entry's GUID and
generation classification visible before either field is consumed.

Actual behavior

The payload stores and cache pointer store/load are ordinary native accesses. There is no release publication,
acquire consumption, or common lock. On Arm64, the pointer can become visible before the payload; the native source
also lacks a compiler-ordering contract on other architectures.

Suggested fix

Repair both accessors as one lock-free protocol. In non-DAC code, release-store the fully initialized pointer from
SetGuidInfo with CoreCLR's VolatileStore, and acquire-load it in GetGuidInfo with VolatileLoad. Preserve the
plain DAC read behind DACCESS_COMPILE, the existing field layout, the single local pointer snapshot, and the rule
that payload fields are immutable after publication.

Every racing writer must initialize all payload bytes before its release store. Every live reader must acquire-load
the same slot before dereferencing the selected entry. Published entries must remain owned by the defining
LoaderAllocator; do not add immediate reclamation. A publish-once CAS could additionally eliminate redundant
first-use allocations, but that broader ownership and first-writer-wins change is not required for this repair.

Safety considerations

The selected change adds no lock, waiting, callback, allocation, exception point, GC-mode transition, or lock-order
edge. It preserves loader-heap lifetime and collectible unload behavior, and it does not individually reclaim
entries that a reader may have observed. The DAC path needs a separate plain accessor because its pointer wrapper
and out-of-process reads do not use the live-process synchronization protocol.

On Windows Arm64, the helpers must lower to release/acquire operations such as STLR/LDAR; a plain load,
volatile-without-barrier load, or address dependency is insufficient. The acquire load is on a COM GUID lookup path
that proceeds to substantially more expensive marshalling and QueryInterface work. A new Crst would add
unnecessary blocking in a cooperative-mode reader and complicate GC suspension, metadata work, and reentrancy.

Zero initialization limits the operational stale-data symptom to zero/partially initialized payload observations;
the entry pointer remains valid for the owning loader's lifetime. No memory corruption, use-after-free, deadlock,
or incompatible-vtable execution was demonstrated.

Suggested validation

Extend the existing process-isolated Windows global ComWrappers tests. Register the global marshalling instance,
have ComputeVtables use literal expected IIDs so it does not warm Type.GUID, and race first-time Type.GUID
and/or typed Marshal.GetComInterfaceForObject calls for cold, public, nongeneric, COM-visible interfaces. Use many
distinct interfaces or fresh processes, assert the exact IID, successful QI and method invocation, correct COM
reference release, and absence of exceptions. Add concurrent publishers, a generated-GUID classification case,
and a no-global-wrapper control.

Stress optimized Windows Arm64 on multiple processors and run Windows x64 compatibility coverage. Inspect optimized
code to confirm release publication and acquire consumption, then run relevant GC-stress and collectible-lifetime
coverage. Instrumentation must not insert ordering between payload initialization and publication. No tests,
builds, codegen inspection, or dynamic reproduction were performed for this draft.

Reviewer synthesis

Both reviews confirmed the missing publication edge, the com bucket, an Arm64-relevant lock-free repair, and no
same-root-cause duplicate; targeted arbitration searches also confirmed no duplicate hit. Reviewer A favored the
minimal release-store/acquire-load repair. Reviewer B preferred an acquire-load plus publish-once CAS and correctly
identified two qualifications adopted here: the reader is Windows-only and requires
ComWrappers.RegisterForMarshalling, and LoaderHeap zero initialization makes a spurious E_NOINTERFACE the
primary consequence rather than corruption. The minimal release/acquire repair is selected because it completely
restores immutable publication while preserving current multiple-writer ownership semantics.

Note

This issue draft was generated with GitHub Copilot and requires maintainer review before filing.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

  • Status
    No status

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions