Skip to content

[cDAC] Add IMetaDataImport COM wrapper over MetadataReader for no-fallback mode#127028

Draft
max-charlamb wants to merge 32 commits intomainfrom
cdac-metadata-import
Draft

[cDAC] Add IMetaDataImport COM wrapper over MetadataReader for no-fallback mode#127028
max-charlamb wants to merge 32 commits intomainfrom
cdac-metadata-import

Conversation

@max-charlamb
Copy link
Copy Markdown
Member

@max-charlamb max-charlamb commented Apr 16, 2026

Note

This PR description was AI/Copilot-generated.

Summary

Implements a managed [GeneratedComClass] wrapper that adapts System.Reflection.Metadata.MetadataReader to the IMetaDataImport/IMetaDataImport2 COM interfaces. This enables SOS and ClrMD to query metadata in cDAC no-fallback mode where the legacy DAC is unavailable.

Motivation

In cDAC no-fallback mode, ClrDataModule.GetInterface() returned NotHandled for IMetaDataImport QIs when _legacyModulePointer == 0, meaning diagnostic tools couldn't access type/method/field metadata. The cDAC already has access to MetadataReader via the EcmaMetadata contract, so a thin COM wrapper bridges the gap.

Changes

File Description
IMetaDataImport.cs Managed [GeneratedComInterface] definitions for IMetaDataImport (62 methods) and IMetaDataImport2 (8 methods) with exact vtable ordering from cor.h
MetadataImportWrapper.cs [GeneratedComClass] implementation wrapping MetadataReader — ~21 real implementations + ~40 E_NOTIMPL stubs
ClrDataModule.cs Wire up wrapper in GetInterface() as no-fallback path with thread-safe lazy init and proper COM ref counting
MetadataImportWrapperTests.cs 21 unit tests using synthetic metadata built with MetadataBuilder

Implemented methods

Enum: EnumTypeDefs, EnumInterfaceImpls, EnumTypeRefs, EnumMembers, EnumMethods, EnumFields, EnumCustomAttributes, EnumGenericParams, CloseEnum, CountEnum, ResetEnum

Properties: GetTypeDefProps, GetTypeRefProps, GetMethodProps, GetFieldProps, GetMemberProps, GetInterfaceImplProps, GetNestedClassProps, GetGenericParamProps

Blob/token: GetRVA, GetSigFromToken, GetCustomAttributeByName, IsValidToken

Design decisions

  • HCORENUM pattern: Uses GCHandle.Alloc to box a MetadataEnum object, matching the native pattern of opaque enumeration handles
  • COM ref counting: Caches the managed wrapper object (not the COM pointer) to avoid ref counting leaks; creates COM interface on demand via StrategyBasedComWrappers
  • Thread safety: Uses Interlocked.CompareExchange for lazy wrapper initialization
  • Error handling: CatchHR() helper normalizes all exceptions to HRESULT values

Testing

All 1651 cDAC tests pass (1630 existing + 21 new).

@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @steveisok, @tommcdon, @dotnet/dotnet-diag
See info in area-owners.md if you want to be subscribed.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a managed COM-compatible IMetaDataImport/IMetaDataImport2 implementation backed by System.Reflection.Metadata.MetadataReader, and wires it into cDAC’s ClrDataModule so diagnostic tools can query metadata when running in no-fallback mode (legacy DAC unavailable).

Changes:

  • Introduces generated COM interface definitions for IMetaDataImport and IMetaDataImport2.
  • Implements MetadataImportWrapper to adapt MetadataReader to the COM metadata import APIs (with a subset implemented and the rest stubbed).
  • Updates ClrDataModule to provide IMetaDataImport in no-fallback mode and adds unit tests validating implemented behaviors.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

File Description
src/native/managed/cdac/tests/MetadataImportWrapperTests.cs Adds unit tests that exercise the wrapper’s enumeration and property APIs over synthetic metadata.
src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/MetadataImportWrapper.cs Implements the COM wrapper over MetadataReader including enum-handle handling and a set of metadata accessors.
src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/IMetaDataImport.cs Adds managed [GeneratedComInterface] declarations for IMetaDataImport/IMetaDataImport2 with vtable ordering.
src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/ClrDataModule.cs Hooks IMetaDataImport QI to return the managed wrapper when legacy DAC is unavailable.

Comment thread src/native/managed/cdac/tests/MetadataImportWrapperTests.cs Outdated
@github-actions

This comment has been minimized.

max-charlamb added a commit that referenced this pull request Apr 17, 2026
The ICustomQueryInterface.GetInterface method in ClrDataModule delegates
IMetaDataImport QIs to the legacy module pointer. Gate this with
CanFallback() so no-fallback mode blocks it, allowing PR #127028's
managed MetadataReader wrapper to provide metadata instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
max-charlamb added a commit that referenced this pull request Apr 17, 2026
- Add ICustomQueryInterface.GetInterface to the allowlist (needed until
  managed MetadataReader wrapper lands in PR #127028)
- Replace file-based logging with Console.Error.WriteLine so blocked
  fallback calls appear directly in test output (captured by ProcessRunner)
- Simplify tracking to ConcurrentDictionary<string, bool> with TryAdd
- Remove Flush() CanFallback gate (cache management, not data retrieval)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jkoritzinsky
Copy link
Copy Markdown
Member

You'll likely need to add support for IMetaDataAssemblyImport as well for assembly-level info

max-charlamb added a commit that referenced this pull request Apr 17, 2026
The ICustomQueryInterface.GetInterface method in ClrDataModule delegates
IMetaDataImport QIs to the legacy module pointer. Gate this with
CanFallback() so no-fallback mode blocks it, allowing PR #127028's
managed MetadataReader wrapper to provide metadata instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
max-charlamb added a commit that referenced this pull request Apr 17, 2026
- Add ICustomQueryInterface.GetInterface to the allowlist (needed until
  managed MetadataReader wrapper lands in PR #127028)
- Replace file-based logging with Console.Error.WriteLine so blocked
  fallback calls appear directly in test output (captured by ProcessRunner)
- Simplify tracking to ConcurrentDictionary<string, bool> with TryAdd
- Remove Flush() CanFallback gate (cache management, not data retrieval)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 17, 2026 17:43
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

Comment thread src/native/managed/cdac/tests/MetaDataImportImplTests.cs Outdated
Comment thread src/native/managed/cdac/tests/MetaDataImportImplTests.cs Outdated
Comment thread src/native/managed/cdac/tests/MetaDataImportImplTests.cs Outdated
Comment thread src/native/managed/cdac/tests/MetaDataImportImplTests.cs
max-charlamb added a commit that referenced this pull request Apr 17, 2026
> [!NOTE]
> This PR description was generated with the assistance of GitHub
Copilot.

## Summary

Add a granular, per-method allowlist (`LegacyFallbackHelper`) that
controls which delegation-only APIs may fall back to the legacy DAC when
`CDAC_NO_FALLBACK=1` is set. This enables selective no-fallback testing
— blocking fallback for most APIs while allowing specific APIs that are
known to not yet be implemented in the cDAC.

Wire this into the runtime-diagnostics CI pipeline using the
`-noFallback` flag from
[dotnet/diagnostics#5806](dotnet/diagnostics#5806).

All fallback attempts (both allowed and blocked) are logged to stderr
with method name, file, and line number for capture by the diagnostics
test infrastructure.

## Changes

### LegacyFallbackHelper.cs — Granular fallback control

New static helper that every delegation-only call site invokes via
`CanFallback()`. Uses `[CallerMemberName]`, `[CallerFilePath]`, and
`[CallerLineNumber]` to identify the call site.

- **Normal mode** (`CDAC_NO_FALLBACK` unset): Always returns `true`
(single `bool` check, `[AggressiveInlining]`)
- **No-fallback mode** (`CDAC_NO_FALLBACK=1`): Checks method name
against a `HashSet<string>` allowlist and file name against a file-level
allowlist

**Per-method allowlist:**

| Method | Reason |
|--------|--------|
| `EnumMemoryRegions` | Dump creation — cDAC has no memory enumeration
implementation |
| `GetInterface` | IMetaDataImport QI ([PR
#127028](#127028)) |
| `GetMethodDefinitionByToken` | IXCLRDataModule — not yet implemented
in cDAC |
| `IsTrackedType` | GC heap analysis ([PR
#125895](#125895)) |
| `TraverseLoaderHeap` | Loader heap traversal ([PR
#125129](#125129)) |

**File-level allowlist:**

| File | Reason |
|------|--------|
| `DacDbiImpl.cs` | Entire DBI/ICorDebug interface (122 methods) —
deferred |

### Entrypoints.cs — Simplified creation

Both `CreateSosInterface` and `CreateDacDbiInterface` now follow the
same pattern: the legacy implementation is always passed through, and
`LegacyFallbackHelper.CanFallback()` at each call site decides whether
to delegate. Removed `prevent_release`, `noFallback` env var check, and
null-legacy-ref logic.

### 13 Legacy wrapper files — Instrumented delegation sites

All 296 delegation-only methods across all legacy wrapper files now call
`LegacyFallbackHelper.CanFallback()`:

- `SOSDacImpl.cs` (12 methods)
- `SOSDacImpl.IXCLRDataProcess.cs` (38 methods, `Flush()` intentionally
excluded — cache management)
- `ClrDataModule.cs` (29 methods + IMetaDataImport QI)
- `DacDbiImpl.cs` (122 methods)
- Other wrappers: `ClrDataTask.cs`, `ClrDataExceptionState.cs`,
`ClrDataFrame.cs`, `ClrDataValue.cs`, `ClrDataTypeInstance.cs`,
`ClrDataMethodInstance.cs`, `ClrDataStackWalk.cs`, `ClrDataProcess.cs`

### CI Pipeline — `-noFallback` flag

Updated `runtime-diag-job.yml` to accept a `noFallback` parameter that
passes `-noFallback` to the diagnostics build script. The
`cDAC_no_fallback` leg in `runtime-diagnostics.yml` now uses
`noFallback: true` instead of setting `CDAC_NO_FALLBACK` as a
pipeline-level environment variable. The `-noFallback` flag (from
[dotnet/diagnostics#5806](dotnet/diagnostics#5806))
properly:

- Sets `DOTNET_ENABLE_CDAC=1` and `CDAC_NO_FALLBACK=1` on the debugger
process
- Defines `CDAC_NO_FALLBACK_TESTING` to skip `ClrStack -i` tests
(ICorDebug not implemented in cDAC)

### Stderr logging

Every fallback attempt is logged to stderr in the format:

```
[cDAC] Allowed fallback: CreateStackWalk at DacDbiImpl.cs:590
[cDAC] Blocked fallback: SomeMethod at SOSDacImpl.cs:123
```

The diagnostics test infrastructure (`ProcessRunner`) captures stderr
and routes it to xunit test output with `STDERROR:` prefix, making
fallback usage visible in test results.

## Test Results

With `CDAC_NO_FALLBACK=1` and the current allowlist, running the full
SOS test suite against a private runtime build:

- **24 passed**, **2 failed** (flaky/pre-existing), **2 skipped**
(Linux-only)
- **0 blocked fallbacks**

## Motivation

The existing cDAC test leg always has the legacy DAC as a fallback, so
unimplemented APIs are silently handled. The granular no-fallback mode
makes gaps visible per-method, helping track progress toward full cDAC
coverage while keeping tests green for known-deferred APIs.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 17, 2026 21:31
@max-charlamb max-charlamb force-pushed the cdac-metadata-import branch from 8f31425 to a67d391 Compare April 17, 2026 21:31
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

@github-actions

This comment has been minimized.

Copilot AI review requested due to automatic review settings April 18, 2026 02:29
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 8 comments.

Comment thread src/native/managed/cdac/tests/MetaDataImportImplTests.cs
Comment thread src/native/managed/cdac/tests/MetaDataImportImplTests.cs Outdated
Max Charlamb and others added 2 commits April 18, 2026 11:53
…ack mode

Implement a managed [GeneratedComClass] wrapper that adapts
System.Reflection.Metadata.MetadataReader to the IMetaDataImport/IMetaDataImport2
COM interfaces. This enables SOS and ClrMD to query metadata in cDAC no-fallback
mode where the legacy DAC is unavailable.

Key changes:
- IMetaDataImport.cs: Managed COM interface definitions with exact vtable ordering
  from cor.h (62 IMetaDataImport + 8 IMetaDataImport2 methods)
- MetadataImportWrapper.cs: Implementation wrapping MetadataReader with ~21 real
  implementations (enum, property, blob/token methods) and ~40 E_NOTIMPL stubs
- ClrDataModule.cs: Wire up wrapper in GetInterface() as no-fallback path with
  thread-safe lazy init and proper COM ref counting
- MetadataImportWrapperTests.cs: 21 unit tests using synthetic metadata

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove 7 unused method implementations (EnumTypeDefs, EnumTypeRefs,
EnumMembers, EnumMethods, EnumCustomAttributes, CountEnum, ResetEnum)
that are not called by SOS or ClrMD, replacing them with E_NOTIMPL stubs.

Add 8 new method implementations needed by SOS/ClrMD consumers:
FindTypeDefByName, GetMemberRefProps, GetParamProps, GetModuleRefProps,
GetTypeSpecFromToken, GetUserString, GetParamForMethodIndex, GetClassLayout.

Extract CLDB_E_RECORD_NOTFOUND to a class-level constant.

Update tests: remove tests for removed methods, add tests for all new
methods, update NotImplementedMethods test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.

Comment thread src/native/managed/cdac/tests/MetaDataImportImplTests.cs
Comment thread src/native/managed/cdac/tests/MetaDataImportImplTests.cs
Comment thread src/native/managed/cdac/tests/DumpTests/MetaDataImportDumpTests.cs
@github-actions

This comment has been minimized.

Max Charlamb and others added 3 commits April 18, 2026 23:07
Add HasReader property with [MemberNotNullWhen(true, nameof(_reader))] so
the compiler tracks _reader as non-null after the guard check. Replace all
'if (_reader is null)' guards with 'if (!HasReader)' and remove 57 uses of
the null-forgiving operator (_reader!).

Private helpers (GetTypeDefFullName, GetTypeRefFullName, BuildInterfaceImplLookup,
GetCustomAttributeTypeName) use Debug.Assert(HasReader) to satisfy flow analysis
since they are only called from contexts where _reader is known non-null.

Also fix MetaDataImportDumpTests to assign Assert.NotNull result for proper
nullable flow analysis.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Changed _reader from MetadataReader? to MetadataReader (non-nullable)
- Removed HasReader property and MemberNotNullWhen attribute
- Removed all 26 if (!HasReader) fallback blocks from implemented methods
- ClrDataModule now returns NotHandled if reader is null (fail fast)
- Removed NullReader_* tests since null reader is no longer valid

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The dump tests use xUnit v2 where Assert.NotNull returns void, unlike
the unit tests which use xUnit v3 where it returns T. Split the assert
and assignment into separate statements.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 19, 2026 12:45
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Comment on lines +33 to +35
Assert.NotNull(reader);

return (reader, new MetaDataImportImpl(reader));
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetRootModuleImport uses MetadataReader? and then returns/passes it as non-nullable MetadataReader. Since MetadataReader is a struct and IEcmaMetadata.GetMetadata returns MetadataReader?, this won't compile; use reader.Value (after checking reader.HasValue) or restructure to avoid nullable.

Suggested change
Assert.NotNull(reader);
return (reader, new MetaDataImportImpl(reader));
Assert.True(reader.HasValue);
MetadataReader metadataReader = reader.Value;
return (metadataReader, new MetaDataImportImpl(metadataReader));

Copilot uses AI. Check for mistakes.
mb.AddTypeSpecification(mb.GetOrAddBlob(typeSpecSig));

// UserString: "Hello, World!"
UserStringHandle userStringHandle = mb.GetOrAddUserString("Hello, World!");
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateTestMetadata declares userStringHandle but never uses it. This triggers CS0219 (warning-as-error) and will fail the build; either remove the local or explicitly discard the result (e.g., assign to _).

Suggested change
UserStringHandle userStringHandle = mb.GetOrAddUserString("Hello, World!");
_ = mb.GetOrAddUserString("Hello, World!");

Copilot uses AI. Check for mistakes.
// FieldDef: StringConst (string) — field row 2, with string constant
FieldDefinitionHandle stringConstField = mb.AddFieldDefinition(
FieldAttributes.Public | FieldAttributes.Static | FieldAttributes.Literal | FieldAttributes.HasDefault,
mb.GetOrAddString("StringConst"), intFieldSig);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StringConst is described as a string field but is created with the intFieldSig signature. That makes the synthetic metadata inconsistent and can hide issues; use a string field signature blob for StringConst.

Suggested change
mb.GetOrAddString("StringConst"), intFieldSig);
mb.GetOrAddString("StringConst"), stringFieldSig);

Copilot uses AI. Check for mistakes.
Comment on lines +155 to +163
// Provider is stored alongside wrapper to prevent GC from collecting pinned metadata memory.
private static MetadataReaderProvider? _testProvider;

private static MetaDataImportImpl CreateWrapper()
{
(MetadataReader reader, MetadataReaderProvider provider) = CreateTestMetadata();
_testProvider = provider;
return new MetaDataImportImpl(reader);
}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateWrapper stores the MetadataReaderProvider in a static field and never disposes it. Since MetadataReaderProvider is IDisposable, this retains metadata buffers for the entire test run; consider returning the provider from CreateWrapper and disposing it per-test (or using a fixture implementing IDisposable).

Copilot uses AI. Check for mistakes.
Comment on lines +1237 to +1242
int copyChars = Math.Min(charCount, (int)cchString);
new ReadOnlySpan<char>(dataPtr, copyChars).CopyTo(new Span<char>(szString, copyChars));

if ((uint)charCount > cchString)
{
szString[cchString - 1] = '\0';
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetUserString copies charCount characters into the buffer but only writes a null terminator on truncation and treats cchString == charCount as non-truncated. This can return a non-null-terminated buffer and incorrect truncation behavior; treat cchString as including space for the terminator (copy at most cchString-1, always write \0 when cchString>0, and return CLDB_S_TRUNCATION when charCount + 1 > cchString).

Suggested change
int copyChars = Math.Min(charCount, (int)cchString);
new ReadOnlySpan<char>(dataPtr, copyChars).CopyTo(new Span<char>(szString, copyChars));
if ((uint)charCount > cchString)
{
szString[cchString - 1] = '\0';
int destinationCapacity = (int)cchString - 1;
int copyChars = Math.Min(charCount, destinationCapacity);
new ReadOnlySpan<char>(dataPtr, copyChars).CopyTo(new Span<char>(szString, copyChars));
szString[copyChars] = '\0';
if ((uint)charCount + 1 > cchString)
{

Copilot uses AI. Check for mistakes.
Max Charlamb and others added 3 commits April 19, 2026 14:03
…am lookup

- Fix CountEnum/ResetEnum to work with cDAC-created enum handles by
  tracking GCHandle values in a HashSet. Previously these delegated to
  legacy which couldn't interpret cDAC GCHandle-based enums.
- Fix CloseEnum to distinguish cDAC vs legacy enum handles, preventing
  crashes when mixing handle types.
- Implement GetClassLayout field offset population. Previously always
  returned pcFieldOffset=0; now fills COR_FIELD_OFFSET array with
  field tokens and their explicit layout offsets.
- Optimize GetParamProps parent method lookup from O(N^2) triple-nested
  loop to O(1) cached dictionary lookup, matching the existing pattern
  used by BuildInterfaceImplLookup.
- Add tests for CountEnum, ResetEnum, and null handle edge cases.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…mbly import methods

- Replace HashSet<nint> with ConcurrentDictionary<nint, byte> for _cdacEnumHandles
  to support concurrent COM method calls from multiple threads.
- Refactor GetAssemblyProps and GetAssemblyRefProps to use
  OutputBufferHelpers.CopyStringToBuffer instead of manual string copy,
  reducing code duplication and ensuring consistent buffer handling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add ContractsDictionaryConverter that handles both string and integer
values in the contracts dictionary. Integer values from older runtimes
are mapped to the 'c<N>' naming convention (e.g., 1 -> 'c1') to match
the current string-based contract version registrations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 20, 2026 18:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Comment on lines +11 to 12
public static unsafe bool CopyStringToBuffer(char* stringBuf, uint bufferSize, uint* neededBufferSize, string str)
{
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing CopyStringToBuffer from void to bool is a breaking change for any external callers of this public helper. If this API is consumed outside this assembly/package, consider preserving the original void signature (and adding a new overload/optional out parameter for truncation) to avoid breaking binary/source compatibility.

Copilot uses AI. Check for mistakes.
@github-actions

This comment has been minimized.

- Only accept IID_IMetaDataImport in ClrDataModule QI (not IMetaDataImport2/AssemblyImport)
- Replace StrategyBasedComWrappers with ComInterfaceMarshaller pattern
- Fix unused userStringHandle variable in tests
- Fix StringConst field to use string field signature instead of int

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 20, 2026 22:43
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.

Comment on lines +224 to +231
if (_cdacEnumHandles.ContainsKey(hEnum))
{
MetadataEnum? e = GetEnum(hEnum);
if (e is null)
return HResults.E_FAIL;
if (pulCount is not null)
*pulCount = (uint)e.Tokens.Count;
return HResults.S_OK;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CountEnum uses _cdacEnumHandles.ContainsKey(hEnum) and then dereferences the GCHandle via GetEnum(hEnum). If another thread calls CloseEnum between those operations, the handle can be freed and GCHandle.Target access may throw (unhandled here). Consider using a single atomic lookup and wrapping GetEnum in a try/catch (or otherwise ensuring the handle can’t be freed concurrently).

Copilot uses AI. Check for mistakes.
Comment on lines +242 to +248
if (_cdacEnumHandles.ContainsKey(hEnum))
{
MetadataEnum? e = GetEnum(hEnum);
if (e is null)
return HResults.E_FAIL;
e.Position = (int)Math.Min(ulPos, (uint)e.Tokens.Count);
return HResults.S_OK;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResetEnum has the same race as CountEnum: ContainsKey followed by GetEnum(hEnum) can observe a handle that gets freed concurrently by CloseEnum, potentially throwing before you can return an HRESULT. Consider an atomic lookup pattern and exception-to-HRESULT handling here as well.

Copilot uses AI. Check for mistakes.
Comment on lines +1320 to +1325
int copyChars = Math.Min(charCount, (int)cchString);
new ReadOnlySpan<char>(dataPtr, copyChars).CopyTo(new Span<char>(szString, copyChars));

if ((uint)charCount > cchString)
{
szString[cchString - 1] = '\0';
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetUserString copies up to cchString characters and only writes a null terminator when charCount > cchString. If the buffer size is intended to include the terminator (as with other *Props APIs), the copy should reserve 1 char for \0 and treat charCount >= cchString as truncation; otherwise callers can get a non-null-terminated buffer when charCount == cchString.

Suggested change
int copyChars = Math.Min(charCount, (int)cchString);
new ReadOnlySpan<char>(dataPtr, copyChars).CopyTo(new Span<char>(szString, copyChars));
if ((uint)charCount > cchString)
{
szString[cchString - 1] = '\0';
int copyChars = Math.Min(charCount, (int)cchString - 1);
new ReadOnlySpan<char>(dataPtr, copyChars).CopyTo(new Span<char>(szString, copyChars));
szString[copyChars] = '\0';
if ((uint)charCount >= cchString)
{

Copilot uses AI. Check for mistakes.
Comment on lines 5 to 7
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Loader;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using System.Runtime.CompilerServices; is currently unused in this file; please remove it to avoid build warnings/noise.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems correct.

if (reader is null)
return CustomQueryInterfaceResult.NotHandled;

wrapper = new MetaDataImportImpl(reader, legacyImport);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reader is a MetadataReader? (nullable struct). Passing it directly into new MetaDataImportImpl(reader, legacyImport) will not compile; unwrap it after the null-check (e.g., reader.Value / GetValueOrDefault()).

Suggested change
wrapper = new MetaDataImportImpl(reader, legacyImport);
wrapper = new MetaDataImportImpl(reader.Value, legacyImport);

Copilot uses AI. Check for mistakes.
- ConvertToUnmanaged already returns an AddRef'd interface pointer, so
  the follow-up Marshal.QueryInterface was unnecessary in both
  ClrDataModule and MetaDataImportImpl ICustomQueryInterface.
- Remove null-forgiving operator on ConvertToManaged since legacyImport
  is already nullable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment on lines 5 to 7
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Loader;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems correct.

- Move field declarations above constructor in ClrDataModule
- Remove stale ComWrappers and ICustomQueryInterface comments
- Move CLDB_* HResults to shared CorDbgHResults.cs
- Use CorElementType enum instead of raw uint constants
- Move validation helpers to bottom of MetaDataImportImpl
- Make GetEnum validate ownership and return non-nullable
- Convert all public methods to explicit interface notation
- Simplify CountEnum/ResetEnum using validated GetEnum
- Update tests for explicit interface notation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 21, 2026 19:11
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.

Comment on lines +103 to +104
return CustomQueryInterfaceResult.NotHandled;

Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If EcmaMetadata.GetMetadata fails (reader is null) but the legacy module pointer can still provide IMetaDataImport, this currently returns NotHandled and the QI fails even in fallback mode. Consider falling back to returning the legacy IMetaDataImport COM pointer when reader is unavailable, preserving the previous behavior for legacy-capable scenarios.

Suggested change
return CustomQueryInterfaceResult.NotHandled;
{
if (legacyImport is null)
return CustomQueryInterfaceResult.NotHandled;
ppv = (nint)ComInterfaceMarshaller<IMetaDataImport>.ConvertToUnmanaged(legacyImport);
return CustomQueryInterfaceResult.Handled;
}

Copilot uses AI. Check for mistakes.
{
int hr = HResults.S_OK;
try
{
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetTypeDefProps currently masks off the token type (td & 0x00FFFFFF) and always treats the input as a TypeDef RID. Native RegMeta returns S_FALSE for non-TypeDef tokens and has special handling for mdTypeDefNil (0). To match expected COM semantics and avoid returning incorrect data for wrong token kinds, validate TypeFromToken(td) and handle td==0 explicitly before reading metadata.

Suggested change
{
{
if (td == 0)
return HResults.S_FALSE;
if ((td & 0xFF000000) != (uint)CorTokenType.mdtTypeDef)
return HResults.S_FALSE;

Copilot uses AI. Check for mistakes.
Comment on lines +316 to +328
TypeReferenceHandle refHandle = MetadataTokens.TypeReferenceHandle((int)(tr & 0x00FFFFFF));
TypeReference typeRef = _reader.GetTypeReference(refHandle);

string fullName = GetTypeRefFullName(typeRef);
bool truncated = OutputBufferHelpers.CopyStringToBuffer(szName, cchName, pchName, fullName);

if (ptkResolutionScope is not null)
{
EntityHandle scope = typeRef.ResolutionScope;
*ptkResolutionScope = scope.IsNil ? 0 : (uint)MetadataTokens.GetToken(scope);
}

hr = truncated ? CorDbgHResults.CLDB_S_TRUNCATION : HResults.S_OK;
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetTypeRefProps also ignores the token type by masking to a RID and unconditionally reading the TypeRef table. Native RegMeta returns S_FALSE for non-TypeRef tokens and treats mdTypeRefNil (0) as a special case (empty name, length=1, scope=nil). Add token-kind validation and nil-token handling to avoid returning the wrong name/scope for unexpected tokens.

Suggested change
TypeReferenceHandle refHandle = MetadataTokens.TypeReferenceHandle((int)(tr & 0x00FFFFFF));
TypeReference typeRef = _reader.GetTypeReference(refHandle);
string fullName = GetTypeRefFullName(typeRef);
bool truncated = OutputBufferHelpers.CopyStringToBuffer(szName, cchName, pchName, fullName);
if (ptkResolutionScope is not null)
{
EntityHandle scope = typeRef.ResolutionScope;
*ptkResolutionScope = scope.IsNil ? 0 : (uint)MetadataTokens.GetToken(scope);
}
hr = truncated ? CorDbgHResults.CLDB_S_TRUNCATION : HResults.S_OK;
const uint MdTypeRefNil = 0;
const uint TypeRefTokenType = 0x01000000;
if (tr == MdTypeRefNil)
{
bool truncated = OutputBufferHelpers.CopyStringToBuffer(szName, cchName, pchName, string.Empty);
if (ptkResolutionScope is not null)
{
*ptkResolutionScope = 0;
}
hr = truncated ? CorDbgHResults.CLDB_S_TRUNCATION : HResults.S_FALSE;
}
else if ((tr & 0xFF000000) != TypeRefTokenType)
{
hr = HResults.S_FALSE;
}
else
{
TypeReferenceHandle refHandle = MetadataTokens.TypeReferenceHandle((int)(tr & 0x00FFFFFF));
TypeReference typeRef = _reader.GetTypeReference(refHandle);
string fullName = GetTypeRefFullName(typeRef);
bool truncated = OutputBufferHelpers.CopyStringToBuffer(szName, cchName, pchName, fullName);
if (ptkResolutionScope is not null)
{
EntityHandle scope = typeRef.ResolutionScope;
*ptkResolutionScope = scope.IsNil ? 0 : (uint)MetadataTokens.GetToken(scope);
}
hr = truncated ? CorDbgHResults.CLDB_S_TRUNCATION : HResults.S_OK;
}

Copilot uses AI. Check for mistakes.
Comment on lines +356 to +387
int hr = HResults.S_OK;
try
{
MethodDefinitionHandle methodHandle = MetadataTokens.MethodDefinitionHandle((int)(mb & 0x00FFFFFF));
MethodDefinition methodDef = _reader.GetMethodDefinition(methodHandle);

string name = _reader.GetString(methodDef.Name);
bool truncated = OutputBufferHelpers.CopyStringToBuffer(szMethod, cchMethod, pchMethod, name);

if (pClass is not null)
*pClass = MapGlobalParentToken((uint)MetadataTokens.GetToken(methodDef.GetDeclaringType()));

if (pdwAttr is not null)
*pdwAttr = (uint)methodDef.Attributes;

if (ppvSigBlob is not null || pcbSigBlob is not null)
{
BlobHandle sigHandle = methodDef.Signature;
BlobReader blobReader = _reader.GetBlobReader(sigHandle);
if (ppvSigBlob is not null)
*ppvSigBlob = blobReader.StartPointer;
if (pcbSigBlob is not null)
*pcbSigBlob = (uint)blobReader.Length;
}

if (pulCodeRVA is not null)
*pulCodeRVA = (uint)methodDef.RelativeVirtualAddress;

if (pdwImplFlags is not null)
*pdwImplFlags = (uint)methodDef.ImplAttributes;

hr = truncated ? CorDbgHResults.CLDB_S_TRUNCATION : HResults.S_OK;
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetMethodProps constructs a MethodDefinitionHandle from (mb & 0x00FFFFFF) without validating that mb is a MethodDef token or handling mdMethodDefNil. In native RegMeta these APIs return S_FALSE for wrong token types. Without validation, passing a non-MethodDef token can return unrelated method data instead of failing. Consider validating the token kind up front (and similarly for GetFieldProps/GetMemberRefProps/etc.).

Suggested change
int hr = HResults.S_OK;
try
{
MethodDefinitionHandle methodHandle = MetadataTokens.MethodDefinitionHandle((int)(mb & 0x00FFFFFF));
MethodDefinition methodDef = _reader.GetMethodDefinition(methodHandle);
string name = _reader.GetString(methodDef.Name);
bool truncated = OutputBufferHelpers.CopyStringToBuffer(szMethod, cchMethod, pchMethod, name);
if (pClass is not null)
*pClass = MapGlobalParentToken((uint)MetadataTokens.GetToken(methodDef.GetDeclaringType()));
if (pdwAttr is not null)
*pdwAttr = (uint)methodDef.Attributes;
if (ppvSigBlob is not null || pcbSigBlob is not null)
{
BlobHandle sigHandle = methodDef.Signature;
BlobReader blobReader = _reader.GetBlobReader(sigHandle);
if (ppvSigBlob is not null)
*ppvSigBlob = blobReader.StartPointer;
if (pcbSigBlob is not null)
*pcbSigBlob = (uint)blobReader.Length;
}
if (pulCodeRVA is not null)
*pulCodeRVA = (uint)methodDef.RelativeVirtualAddress;
if (pdwImplFlags is not null)
*pdwImplFlags = (uint)methodDef.ImplAttributes;
hr = truncated ? CorDbgHResults.CLDB_S_TRUNCATION : HResults.S_OK;
const uint MethodDefTokenType = 0x06000000;
int hr = HResults.S_OK;
try
{
if ((mb & 0xFF000000) != MethodDefTokenType || (mb & 0x00FFFFFF) == 0)
{
hr = HResults.S_FALSE;
}
else
{
MethodDefinitionHandle methodHandle = MetadataTokens.MethodDefinitionHandle((int)(mb & 0x00FFFFFF));
MethodDefinition methodDef = _reader.GetMethodDefinition(methodHandle);
string name = _reader.GetString(methodDef.Name);
bool truncated = OutputBufferHelpers.CopyStringToBuffer(szMethod, cchMethod, pchMethod, name);
if (pClass is not null)
*pClass = MapGlobalParentToken((uint)MetadataTokens.GetToken(methodDef.GetDeclaringType()));
if (pdwAttr is not null)
*pdwAttr = (uint)methodDef.Attributes;
if (ppvSigBlob is not null || pcbSigBlob is not null)
{
BlobHandle sigHandle = methodDef.Signature;
BlobReader blobReader = _reader.GetBlobReader(sigHandle);
if (ppvSigBlob is not null)
*ppvSigBlob = blobReader.StartPointer;
if (pcbSigBlob is not null)
*pcbSigBlob = (uint)blobReader.Length;
}
if (pulCodeRVA is not null)
*pulCodeRVA = (uint)methodDef.RelativeVirtualAddress;
if (pdwImplFlags is not null)
*pdwImplFlags = (uint)methodDef.ImplAttributes;
hr = truncated ? CorDbgHResults.CLDB_S_TRUNCATION : HResults.S_OK;
}

Copilot uses AI. Check for mistakes.
{
if (fieldOffsets is not null && count < cMax)
{
// Each entry is {ridOfField (uint), ulOffset (uint)}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says each layout entry stores a "ridOfField", but the code writes the full FieldDef token (MetadataTokens.GetToken). This is correct behavior for COR_FIELD_OFFSET, but the comment is misleading—update it to refer to the FieldDef token to avoid confusion.

Suggested change
// Each entry is {ridOfField (uint), ulOffset (uint)}
// Each entry is {FieldDef token (uint), ulOffset (uint)}

Copilot uses AI. Check for mistakes.
Comment on lines +160 to +167
// Provider is stored alongside wrapper to prevent GC from collecting pinned metadata memory.
private static MetadataReaderProvider? _testProvider;

private static IMetaDataImport2 CreateWrapper()
{
(MetadataReader reader, MetadataReaderProvider provider) = CreateTestMetadata();
_testProvider = provider;
return new MetaDataImportImpl(reader);
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateWrapper stores MetadataReaderProvider in a static field and overwrites it per test without disposing the previous instance. Since MetadataReaderProvider holds unmanaged resources, this leaks one provider per test method in this class. Consider disposing the previous provider before overwriting, or using an xUnit fixture/IDisposable pattern so each test disposes its provider deterministically.

Copilot uses AI. Check for mistakes.
@github-actions
Copy link
Copy Markdown
Contributor

🤖 Copilot Code Review — PR #127028

Note

This review was generated by Copilot (Claude Opus 4.6), with additional perspectives from GPT-5.3-Codex and Claude Haiku 4.5.

Holistic Assessment

Motivation: This PR implements a managed MetaDataImportImpl wrapper backed by System.Reflection.Metadata.MetadataReader for the cDAC, replacing legacy DAC QI-based fallback for IMetaDataImport/IMetaDataImport2/IMetaDataAssemblyImport. This is well-motivated — it moves metadata reading into managed code with the standard MetadataReader API, with comprehensive #if DEBUG parity validation against the legacy implementation.

Approach: Sound. The implementation covers the most-used IMetaDataImport methods with managed MetadataReader while 54 less-commonly-used methods delegate to the legacy DAC. The COM interop changes to return IMetaDataImport2 vtables address a known ClrMD vtable-boundary issue. The #if DEBUG cross-validation blocks are an excellent technique for ensuring semantic parity.

Summary: ⚠️ Needs Human Review. The implementation is well-structured and largely correct. Two items need human attention: (1) lazy initialization of shared lookup dictionaries using ??= is technically racy per the project's own thread-safety rules (the SKILL.md says "??= operator is not thread-safe"), and (2) the GetUserString implementation doesn't null-terminate non-truncated output, which may differ from some callers' expectations. Neither is a definitive blocker, but both warrant human judgment.


Detailed Findings

⚠️ Non-atomic lazy initialization with ??= — Thread-safety inconsistency

_interfaceImplToTypeDef and _paramToMethod use ??= for lazy init (MetaDataImportImpl.cs:554, 1336):

_interfaceImplToTypeDef ??= BuildInterfaceImplLookup();
_paramToMethod ??= BuildParamToMethodLookup();

Similarly _metaDataImportImpl in ClrDataModule.cs:107 (which has an explicit comment acknowledging the race).

While reference-type assignments are atomic in .NET and the worst case is benign duplicate initialization, this is inconsistent with the explicit ConcurrentDictionary used for _cdacEnumHandles (line 29). The comment on line 28 states "COM objects may be called from multiple threads". Per dotnet/runtime review conventions, ??= is flagged as not thread-safe — consider Interlocked.CompareExchange for consistency with the stated threading model. (Flagged by all three models — advisory, not merge-blocking)

⚠️ GetUserString doesn't null-terminate non-truncated output — Verify native parity

In MetaDataImportImpl.cs:1221-1232, when szString is provided and the buffer is large enough, character data is copied but no null terminator is appended. A null terminator is only written when the output is truncated (line 1229: szString[cchString - 1] = '\0').

This may be correct for the #US heap semantics (callers should use pchString for the length), but if any consumer relies on null-termination of the non-truncated output, this would be a behavioral difference from the native implementation. The #if DEBUG validation only checks pchString, not the buffer contents. A human reviewer familiar with the native GetUserString contract should confirm. (Low confidence — flagged for verification)

💡 GetAssemblyFromScope always returns 0x20000001 — Minor edge case

MetaDataImportImpl.cs:1652-1655 unconditionally returns the assembly token:

if (ptkAssembly is not null)
    *ptkAssembly = 0x20000001;

Native RegMeta returns CLDB_E_RECORD_NOTFOUND when the Assembly table is empty (netmodules). In practice, all cDAC-loaded modules have assemblies, making this a non-issue for real-world usage. Documenting this assumption with a comment would be beneficial. (Flagged by GPT-5.3-Codex — follow-up, not blocking)

💡 Enum handle TOCTOU — Defensive programming suggestion

GetEnum (MetaDataImportImpl.cs:77) checks ContainsKey then calls GCHandle.FromIntPtr, while CloseEnum can TryRemove and Free the handle between these operations. This is only exploitable if a caller uses and closes the same enum from different threads concurrently (a caller API violation). The native implementation has the same characteristic. Consider using TryGetValue on _cdacEnumHandles as a one-step check+retrieve pattern if the dictionary value can carry meaning, but this is minor. (Flagged by Claude Haiku 4.5 — follow-up, not blocking)

✅ COM interop reference counting — Correctly balanced

In ClrDataModule.cs:91-96: Marshal.QueryInterface → AddRef'd pointer → ComInterfaceMarshaller.ConvertToManaged creates RCW → Marshal.Release balances the ref count. In ClrDataModule.cs:111: ConvertToUnmanaged returns an AddRef'd pointer → returned as ppv, which is the correct COM QI contract (callee AddRefs before returning).

GetUserString raw heap access — Correct boundary validation

The implementation (MetaDataImportImpl.cs:1188-1250) correctly: reads from raw #US heap via MetadataPointer to match native behavior; validates blob fits within remaining heap; rejects even-sized blobs (missing terminal byte) as corrupt; calculates charCount = (blobSize - 1) / sizeof(char). This is a careful re-implementation of the native RegMeta behavior.

CopyStringToBuffer truncation reporting — Correct

The voidbool return change in OutputBufferHelpers.CopyStringToBuffer correctly reports truncation via strSpan.Length + 1 > bufferSize, properly accounting for the null terminator. All callers correctly propagate CLDB_S_TRUNCATION when truncated.

#if DEBUG parity validation — Comprehensive

Every implemented method includes #if DEBUG blocks that call the legacy implementation and compare results field-by-field, including byte-level blob comparison via ValidateBlobsEqual. The ValidateHResult extension method with AllowDivergentFailures mode is a pragmatic approach. This is excellent engineering for a reimplementation.

MapGlobalParentToken — Correct native parity fix

The helper (MetaDataImportImpl.cs:1702-1706) maps TypeDef RID 1 (<Module>) to mdTypeDefNil (0), matching native RegMeta behavior for GetMethodProps, GetFieldProps, and GetMemberRefProps. Test coverage confirms this at line 860 (GetMethodProps_GlobalMethod_ReturnsMdTypeDefNil).

✅ Test coverage — Good breadth

35+ unit tests cover all implemented methods, edge cases (invalid tokens, not-found records, buffer truncation), pagination, dispatch routing (GetMemberProps), constants with and without values, and string constants returning character counts. 3 dump-based integration tests verify against real module metadata. The _testProvider field pattern correctly prevents GC collection of pinned metadata memory during tests.

Generated by Code Review for issue #127028 ·

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants