Registration diagnostics
Consuming a bound C# package (see Consuming C# in Kotlin) starts with a registration step: every C# [ModuleInitializer] hands its function pointers to the matching Kotlin register export at process startup. This page is what to read when that step doesn't behave: a missing native library, a stale half of the package, or a type that never registered. A type or member excluded when Kotlin bindings were generated is a different problem, a Gradle build warning rather than a registration failure; see The bridgeable subset.
Stale build: registration contract mismatch
Before storing anything, each register export compares a slotCount and a contractHash the C# shim passes against this native library's own compile-time values. For a struct-typed reference, the hash covers each component's name as well as its type, so reordering two same-typed fields is source-compatible for C# callers but still changes the hash and is caught here:
The C# shim ships as source (contentFiles/cs/any/) compiled into your assembly, while the register export lives in the separately built native library. NuGet caches by version, so it's routine for one half to lag the other. Fix: purge the cached package (~/.nuget/packages/<packageId>), delete the consuming project's obj//bin/, and rebuild both sides.
Native library or export missing
Each [ModuleInitializer] also wraps its register call, so a load failure names its cause before it rethrows the original exception:
The first means the native library itself never loaded. The second means the library loaded but doesn't contain this export, an older native library paired with a newer C# shim, the same stale-build fix as the contract mismatch above. Both messages print unconditionally, not only under NUGET_INTEROP_TRACE.
Nothing registered, or one type missing
When a generated stub finds its function pointer still null, it throws with one of two messages, distinguishing zero registrations from a partial result, since they're different bugs:
Zero registrations point at a stale obj/project.assets.json, fixed the same way as the contract mismatch above. A partial count proves the shim compiled and the native library loaded, so look only at the missing type's own {Type}Registration.cs and its [ModuleInitializer].
Tracing registration
Set NUGET_INTEROP_TRACE=1 (also true or all) to log a line per registration on both sides of the bridge, off by default. Redirect it to a file with NUGET_INTEROP_TRACEFILE=<path> (opened in append mode and flushed per line, so it survives a crashed host); otherwise it goes to stderr, since some test runners (xunit v2 included) don't capture stdout/stderr.
[nuget:shim] lines come from C#, printed before and after the register P/Invoke; [nuget] lines come from inside the Kotlin export. If the process dies inside a P/Invoke, the last enter line with no matching ok names the type that killed it. [ModuleInitializer] order is up to the CLR, so each Kotlin line carries its own running [m/N] count rather than assuming a position.
This line fires for every consumer, including one that never binds a C# package, gated by the same two variables:
<library> is the DllImport library name your generated bindings use; <version> is read at load time through a dedicated nuget_runtime_version export rather than the version the generator expected, so a stale native library shows up here even when the C# shim compiled clean. If the library predates that export, the line instead reads [nuget:interop] runtime version unavailable from <library>: <ExceptionType>: <message>.
There is no per-call trace: every diagnostic on this page is registration-granularity, checked once per bound type at process start, not on the bridge-call path.
Checking for a forward handle leak
NugetMarshal.LiveHandles, generated into the same shim, reports how many Kotlin StableRef handles the forward bridge (a Kotlin object passed to C#) currently holds. It's internal, so code you write in the same consuming assembly can read it: snapshot the count, run the operation you suspect leaks, then compare. NugetBridge.GcCollect() (also internal, in the same shim) forces a pending release round before you re-read the count, since a release lands on a later GC cycle, not promptly. The count is process-global, so isolate the check from anything else running in the process that crosses a handle at the same time.
Forward direction has no registration step
Kotlin exports called from C# resolve by symbol name through an ordinary P/Invoke: no contract check and no register table. A mismatch there surfaces as a plain DllNotFoundException (the native library didn't load) or EntryPointNotFoundException (the symbol isn't in it) at the first call, rather than one of the messages above. The [nuget:interop] line is the only added signal on this path; tracing which native asset the .NET host actually resolved is outside what this feature covers.