Skip to main content
Interop & Native AOT Scenarios

Why Your Interop Marshaler Silently Swallows Errors in Native AOT — And How to Surface Them

You've been debugging a crash for hours. The stack trace points to a native library call, but the managed side shows nothing—no exception, no error code. Just an abrupt exit. If you're using .NET Native AOT with interop, this scenario is more common than you'd think. The default marshaler swallows errors silently, leaving you in the dark. Here's why that happens and how to make errors visible again. The Decision: Whose Job Is It to Surface Errors? The silent default: what the marshaler hides When you call a native function through the default interop marshaler, you're trusting a black box. The marshaler copies data, translates types, and — if something goes off — it usually just returns a default value or throws a generic AccessViolationException that tells you nothing about the actual failure.

You've been debugging a crash for hours. The stack trace points to a native library call, but the managed side shows nothing—no exception, no error code. Just an abrupt exit. If you're using .NET Native AOT with interop, this scenario is more common than you'd think. The default marshaler swallows errors silently, leaving you in the dark. Here's why that happens and how to make errors visible again.

The Decision: Whose Job Is It to Surface Errors?

The silent default: what the marshaler hides

When you call a native function through the default interop marshaler, you're trusting a black box. The marshaler copies data, translates types, and — if something goes off — it usually just returns a default value or throws a generic AccessViolationException that tells you nothing about the actual failure. I have debugged assembly crashes where the only clue was a mangled structure: a pointer that should have been null became a garbage address, and the marshaler never logged a warning. That's the silent default — it hides the seam where your managed world meets the native world, and it assumes everything worked.

The catch is that in a JITted environment, you can often catch these errors at runtime, patch around them, or recompile with debug flags. Native AOT removes that safety net. There's no JIT to catch mismatched signatures, no dynamic type checking, no fallback to a debugger-friendly error path. The marshaler in Native AOT is compiled ahead-of-window, meaning every marshaling decision is baked into the binary. If a marshaling attribute is off — say you used [MarshalAs(UnmanagedType.LPWStr)] when the native side expects ANSI — the system won't tell you at compile phase. It will silently corrupt data or crash with a meaningless memory access error.

'The opening phase I ran a Native AOT binary against a C library, the interop layer just ate a return code. No exception, no log — just a corrupted heap and a segfault three function calls later.'

— Real conversation from a .NET interop debugging session, 2024

Why Native AOT changes the error-handling game

Most crews skip this: Native AOT forces a deliberate choice on error handling because the marshaler can't adapt. In a JIT scenario, the runtime can inject checks, retry marshaling with different strategies, or even recompile a method if an error pattern is detected. Native AOT has none of that. The generated code is fixed. If you didn't write error handling into your interop layer, you get nothing.

What usually breaks primary is the return value. A native function might return HRESULT or errno — the marshaler knows this. But unless you explicitly surface that code via [DllImport(SetLastError = true)] or a custom marshaling wrapper, the error is silently discarded. The managed caller receives a default struct or a zero, and the actual failure reason vanishes. That hurts. We fixed this in a gRPC interop layer by wrapping every native call in a helper that checked the last error code and threw a descriptive exception — but we only discovered the problem because a customer's audio processing pipeline returned garbage silently for six weeks.

The decision then becomes: who owns this responsibility? Library authors can embed error surfacing into their public API — throwing exceptions, returning result objects, or logging. App developers, however, inherit whatever that library exposes. If the library author chose silence, the app developer must either reverse-engineer the native calls or patch around the marshaler. Neither option is fast.

Who needs to decide: library authors vs. app developers

The line is fuzzy. A library author shipping a NuGet package with Native AOT support must decide how errors surface. If you wrap every native call in a try/catch that swallows SEHException, you have chosen silence. App developers consuming that library have no recourse short of forking the package. I have seen this pattern in three different industrial IoT libraries — all chose to suppress errors "for performance" and left customers debugging corrupt telemetry data.

flawed order. The performance spend of surfacing an error is almost always lower than the debugging cost of hiding it. If you're the library author, surface the error. Use [LibraryImport] with explicit error handling, return a discriminated union, or throw a custom exception that includes the native error code. If you're the app developer, audit your dependencies. Search for bare [DllImport] calls that don't check return values — those are the silent window bombs.

The trade-off is real: adding error handling adds branches, which increases binary size and may slow hot paths. However, a crash with a clear message is infinitely more debuggable than a corrupted heap that surfaces three hours into a manufacturing run. Choose who pays the cost — compilation size or debugging phase. Native AOT doesn't let you defer the decision anymore.

Three Approaches to Surface Errors in Native AOT

Custom marshaling with explicit error propagation

The most direct way to surface interop errors in Native AOT is to write a custom marshaler that refuses to hide failures. Instead of letting the runtime swallow exceptions at the boundary, you intercept every return value—manually. I have seen groups wrap native calls in a thin C# layer that checks the result before the managed code even sees it. You parse the HRESULT, the errno, or whatever status code your C library spits out, and you throw a meaningful InteropException right there. That sounds clean, and it works—until you realize you're writing one marshaler per function signature. The trade-off? You own every seam. flawed order of cleanup or a missed finally block and you leak handles faster than you can debug them.

The catch is that custom marshaling in Native AOT doesn't get the same JIT optimizations you'd see in CoreCLR. The code is compiled ahead-of-slot, which means your error-propagation logic is static—no runtime patching, no dynamic fallback. That hurts when you're dealing with a library that returns errors in non-standard ways. I fixed a serial-port binding once where the C library used a global error flag instead of return codes. We had to marshal two things: the function output and a separate state query. Most crews skip this method because it feels like too much boilerplate. But boilerplate beats silent data corruption every slot.

'A custom marshaler that throws on every non-zero HRESULT will surface errors you didn't know existed—but it will also surface errors the vendor never intended you to see.'

— A respiratory therapist, critical care unit

— engineer who inherited a legacy COM wrapper, retrospect

Reality check: name the development owner or stop.

Reality check: name the development owner or stop.

Sprint drills, plyometric hops, tempo runs, mobility circuits, and cool-down walks load joints differently after travel weeks.

Heddle selvedge weft drifts left.

Manual P/Invoke wrappers that check HRESULT

This is the middle-ground: skip custom marshaling altogether and wrap your [DllImport] calls in a hand-written C# method that does one thing—inspect the return value before passing it along. You call the native function, store the result, then throw if it signals failure. No marshaler attribute, no magic. The clarity is brutal and honest. What usually breaks opening is the assumption that every failed call returns a distinct HRESULT. Some libraries return S_OK but populate an out parameter with garbage. Others return E_FAIL for everything. I have watched a team spend three days chasing a bug that was just a vendor using E_FAIL for both "file not found" and "network timeout." Manual wrappers don't fix that—they just expose the lie faster.

The trick with manual P/Invoke in Native AOT is that you can't rely on the runtime's built-in exception handling for interop calls. The native stack is frozen, so a C++ exception crossing back into your C# code won't unwind cleanly. That means your wrapper must catch the native error before the runtime does anything else. A pattern I have used: wrap every native call in a try block that only handles your custom interop exception, then let all other exceptions crash. Crashing is better than swallowing—your logs will show the call site, the HRESULT, and the stack trace. Crash-and-log is not elegant, but it's a damn sight better than a silent faulty answer in output.

Source-generated marshaling (LibraryImport)

Source generators changed the game for Native AOT—but only if you understand what they don't generate. The [LibraryImport] attribute tells the compiler to produce marshaling code at build slot, bypassing the runtime's IL stub generation. That means your interop calls are faster and AOT-compatible from the start. The problem? By default, source generators treat error handling as optional. They don't inject error checks unless you explicitly tell them to. Most groups skip that part. They see "works in AOT" and assume the error behavior matches CoreCLR. It doesn't.

Source-generated marshaling requires you to define the error shape yourself. You can set SetLastError = true on the [LibraryImport] attribute, then call Marshal.GetLastPInvokeError() after every invocation—but that only captures Win32-style errors. For HRESULT-based APIs, you have to annotate the return type or write a custom MarshalAs attribute. I saw a team ship a Native AOT binary where every interop call returned 0 for success and -1 for failure. Their source generator didn't check the return value, because they never told it to. The output ran fine for weeks, silently skipping every failed write. Source generators are powerful—but they're not psychic. If you don't describe the error contract at compile phase, the generator will happily produce code that ignores it.

How to Choose: Criteria That Actually Matter

Debuggability — can you actually see where it broke?

This is the opening filter, because without sight you're flying blind. The raw P/Invoke return-code method gives you a number — maybe zero, maybe something cryptic like 0x80004005. Good luck mapping that to a call stack in a trimmed binary. I have seen units spend two days hunting a marshaling bug that was literally one off UnmanagedType attribute. The structured exception handler tactic? It catches the crash but often loses the context — you know something died, but not which interop call triggered it. The explicit-throw wrapper, by contrast, preserves the stack trace. You get the file, the line, the parameter name. That sounds fine until you realize the wrapper itself can be trimmed away if it's not attributed correctly. The catch: Native AOT's linker doesn't play nice with dynamically resolved error metadata. off order — and your nice error surfaces as a generic NullReferenceException with zero provenance.

Performance — does error checking kill your throughput?

Most units skip this criterion until output. Then the latency spikes hit. The return-code path is cheap — a single integer comparison. But you pay for it in readability: every call site needs an if-statement, and those add up across a hot loop. The structured exception method is not free — exceptions in Native AOT are slower than on full framework, sometimes 3x to 5x slower per throw. That hurts. The explicit-throw wrapper sits in the middle cost-wise, but the real expense is hidden: attribute reflection. If your wrapper uses Marshal.GetLastWin32Error() after every call, you're adding a thread-local storage lookup that kills cache locality. I measured a 12% throughput drop once just from that single call inserted naively. The trade-off is stark: silent and fast, or visible and slower. You have to pick your poison based on where milliseconds actually matter. A background sync job? Let it throw. An audio rendering callback? Keep it silent and log elsewhere.

One concrete anecdote: we fixed a real-slot signal-processing interop by moving from exception-based error surfacing to a dedicated error-queue pattern — the marshaling code pushed error codes into a concurrent bag, and a separate background thread drained it. Throughput recovered, debuggability stayed acceptable. Not a silver bullet, but it survived trimming.

Maintainability — how painful is the next developer's Monday morning?

The return-code method leaves breadcrumbs everywhere. Each interop call gets an if-statement, and those if-statements drift as the underlying C API changes. You update a DllImport signature but forget to update the error handler — now you're checking for ERROR_SUCCESS against a function that returns HRESULT. That hurts. The structured exception method hides the messy logic in a central handler — elegant until you need to change error-handling behavior for one specific call. Now you're adding filter expressions or conditional clauses that balloon into spaghetti. Honestly—the explicit-throw wrapper wins here if you build it right. A single generic wrapper method handles all the translation logic. Update the wrapper, update every call site. That said, the wrapper itself becomes a dependency magnet: everyone starts piling on special cases. "Oh, and for WriteFile we need to retry on ERROR_LOCK_VIOLATION." Next thing you know, the wrapper is 400 lines and nobody wants to touch it.

AOT compatibility — will trimming eat your tactic?

This is the silent killer. Native AOT strips reflection metadata aggressively. The return-code tactic survives because it's just integers and comparisons — no reflection required. The structured exception method works as long as your exception types are rooted (they usually are, unless you subclass System.Exception dynamically — don't). The explicit-throw wrapper is the riskiest: if your wrapper uses dynamic or Activator.CreateInstance to instantiate error objects, the linker sees nothing and removes the type. Boom — silent failure again, just at a different layer. You must annotate with [DynamicDependency] or [DynamicallyAccessedMembers]. We once shipped a build where the error type was trimmed away, and the wrapper silently returned null for every error. That was a fun debugging week. The rule: if your error-surfacing method uses any runtime type discovery, you must test it on a fully trimmed publish before trusting it.

'The wrapper that throws on error is only as good as the last trim-safe attribute you remembered to add.'

— internal post-mortem note, after a output incident

Trade-Offs: What Each tactic Costs You

Custom marshaling: control vs. complexity

You get surgical precision — every byte, every error path, hand-tuned. The catch is the cost. I've watched crews spend three days debugging a single struct layout that looked right but wasn't. Custom marshaling means you own the seam between managed and native memory. That seam bleeds. One misaligned field, one missing [StructLayout(LayoutKind.Sequential)], and your error surfaces become error *masks* — because now your custom code is the thing failing, not the interop layer. The hidden price? Maintenance. Every window the native API changes a parameter type, you dig back into that custom marshaler. It's not just code; it's a contract you now personally guarantee.

Most crews skip this: you also lose the JIT's ability to inline, to optimize. Native AOT is already stripping away some of that, but custom marshalers add an indirection layer that the tree-shaker can't always eliminate. Faster execution? Possibly. Faster debugging? Never.

Manual wrappers: transparency vs. boilerplate

Manual wrappers — explicit [DllImport] plus a C# try/catch wrapper — are the most *honest* approach. You see exactly where errors can appear. No magic. The trade-off, and it's a brutal one: sheer volume. A typical native library with 200 functions generates maybe 600 lines of interop code. Your manual wrapper? Fifteen hundred lines. Easy to audit. Exhausting to write. Terrible to maintain across releases.

I fixed this once by generating the wrappers from C header files — until the native team shipped a patch that renamed three fields. Broke everything. The transparency is real, but it's transparency into a pile of code no one wants to touch. What usually breaks primary is the error mapping: you map native HRESULT to a managed exception, miss one code path, and the caller gets a generic COMException with a hex number. That's not surfacing errors — that's repackaging silence.

Odd bit about development: the dull step fails initial.

Odd bit about development: the dull step fails initial.

Buttonholes, snaps, zippers, hooks, rivets, eyelets, and magnetic closures each need discrete QC steps before boxing.

Sourdough starters, miso crocks, koji trays, pickle brines, and yogurt cultures punish vague fermentation logs.

Heddle selvedge weft drifts left.

Fjords kelp basalt look wild.

Best for small libraries (≤30 functions) where you control both sides. Worst for anything larger; you'll live in boilerplate hell.

Source generators: speed vs. tooling maturity

Source generators — like LibraryImport — promise zero-boilerplate interop. Write the signature, get the marshaler for free. Sounds dreamy. That sounds fine until you hit a generator bug in Native AOT that silently drops error handling for a specific calling convention. It happened to us with ThisCall on ARM64 — the generator emitted no exception handler at all. The application ran. It returned flawed data. No crash. No log. That hurts.

The real trade-off isn't just bug risk — it's opacity. When a generator produces wrong code, where do you look? The generated file is huge, unreadable, and often missing line mappings. You can't step into it. You can't annotate it. You trade manual effort for automated mystery. The benefit, though, is speed: development slot drops 60% for typical interop surfaces. And if you stay on well-trodden paths — blittable types, simple char marshaling, x64 — generators are shockingly solid. The edge cases are where errors go to die.

'Generators don't eliminate error surfacing — they relocate the silence to a file you never write and rarely read.'

— paraphrased from a .NET interop maintainer's tweet, circa 2023

Your choice boils down to this: do you trust the toolchain to handle your specific native API shape? If yes, generators are the fastest path. If no — if you're targeting a custom calling convention, complex unions, or cross-platform ABI quirks — you pay for that speed with debugging time that no benchmark can quantify.

Implementation Path: From Silent to Visible Errors

Step 1: Identify the interop calls that swallow errors

Before you fix anything, you need to find the silent failures. That sounds obvious, but most crews skip this—they assume the P/Invoke either works or crashes. In Native AOT, the runtime can't always translate a native failure (like a returned NULL or a non-zero HRESULT) into a managed exception. It just returns a default value. You get a false where you expected true. A 0 where you needed a handle. The application keeps running—wrongly. The trick is to audit every [DllImport] signature and ask: does this native function have a documented error channel? If yes, does my managed caller check it? I have seen output bugs where a C library returned ERROR_FILE_NOT_FOUND for weeks, and the C# code blithely treated the zero-length buffer as valid data. So: grep for DllImport, look at return types, and mark every call where the error signal is an out-parameter, a return code, or NULL. That's your target list.

Step 2: Add a custom marshaler that throws on failure

Now you write a ICustomMarshaler that intercepts the native return before it reaches your business logic. The pattern: implement MarshalNativeToManaged to check the raw native value. Is it an error code? Is the pointer IntPtr.Zero when it shouldn't be? Throw an InvalidOperationException right there. Not a warning. Not a log line. An exception. That forces the caller to handle it—or crash early, which is better than corrupting data silently. Here's the catch: the marshaler runs inside the interop layer, so you have access to Marshal.GetLastWin32Error() or the raw nint. We fixed a file-system enumeration bug this way—the native FindFirstFile returned INVALID_HANDLE_VALUE, but the old wrapper returned null and the code treated that as "no files found." The custom marshaler checked the handle value and threw. Suddenly, the test suite caught the real error: missing permissions, not an empty folder. One warning: custom marshalers add allocations and indirection. In high-throughput interop (millions of calls per second), the cost might sting. Profile initial.

Step 3: Test with a deliberately failing native call

You can't trust the fix until you prove it works. Write a small native function (or mock using NativeLibrary + Marshal.GetDelegateForFunctionPointer) that always returns an error—say, -1 and sets SetLastError(true). Call it from your managed test. Expected behavior: the custom marshaler throws. Actual behavior: I have seen groups discover that their ICustomMarshaler was never instantiated because the attribute was on the wrong parameter. Or that Native AOT trimmed the marshaler class entirely. That hurts. Run the test in Release mode with Native AOT publishing. If it silently returns a default again, you missed something—check the Native AOT linker root for your marshaler type. I once spent a day tracing a bug where the custom marshaler worked in debug but was omitted from the compiled binary because it was only referenced via reflection. The fix: add a [DynamicDependency] or a direct reference in code. Your test is the final proof that the error surfaces—not as a log line in a file nobody reads, but as an exception that stops the train.

Risks of Ignoring Error Surfacing

Silent Data Corruption — The Insidious One

You don't notice corrupted data until someone reconciles quarterly numbers. By then, it's too late. I've seen this firsthand: a native C library returned error codes in a dedicated out-parameter, but the C# marshaler only checked the HRESULT — which was S_OK. The error code sat ignored. The marshaled result looked normal, but the underlying buffer contained stale bytes from a previous operation. That stale data propagated through three microservices before anyone screamed. The real cost? Two weeks of backfill, a pissed-off finance team, and a postmortem that boiled down to 'we didn't surface the error.'

The catch is that silent corruption rarely triggers alerts. Your logs look clean. Health checks pass. Memory isn't leaking. But every consumer of that interop boundary is building on sand. And sand shifts. If your native API ever changes its error-reporting contract — say, switching from HRESULT to a custom status struct — the corruption becomes a time bomb. Most units skip this: they test the happy path, the marshaler passes, and they ship. Wrong order. The unhappy path is where careers derail.

One concrete example from a telemetry ingestion pipeline: a native sensor API returned STATUS_BUFFER_OVERFLOW inside a union field. The C# side read the union as a string. No crash — just silently truncated measurements. That drifted into downstream ML training sets. Engineers spent three months chasing phantom model degradation. Had they surfaced that one error code, they'd have fixed it in an afternoon. Instead, they blamed the model.

Hard-to-Reproduce assembly Crashes

Nothing haunts a team like a crash that won't reproduce locally. Native AOT makes this worse — you lose the JIT's rich exception diagnostics. What happens: a customer reports intermittent access violations. You replay the same inputs, same marshaling code, same everything — and it works fine. That's because the error only surfaces when a specific memory layout occurs at runtime: a null pointer deep inside a struct, a corrupted vtable entry, or a misaligned buffer that only triggers on certain allocator states.

Without explicit error surfacing, these crashes look like heap corruption. They aren't. They're unhandled interop errors that bubbled up as garbage. One team I consulted spent four weeks blaming their memory allocator. The real culprit? A native callback that returned E_PENDING (meaning 'try again later'), but the C# wrapper treated every non-zero return as success. The marshaler swallowed the error, wrote junk into the output buffer, and the next read from that buffer caused a segfault — but only on machines with aggressive NUMA node balancing. Reproduce that in staging. Good luck.

The rhetorical question you should ask before shipping any AOT interop: 'If this native call fails, will the failure look like something else?' If the answer is yes, you've just signed up for a debugging nightmare. The fix isn't more logging — it's surfacing the error at the boundary, in the type system, before it mutates into a crash signature nobody recognizes.

Not every development checklist earns its ink.

Not every development checklist earns its ink.

Policy memos, stakeholder maps, budget riders, sunset clauses, and public comment windows reshape what looks optional.

Fjords kelp basalt look wild.

Versioning Nightmares When Native API Changes

Native APIs change. They don't send you a memo. What breaks opening is rarely the happy path — it's the error paths that you left unmapped. Consider a native library that adds a new error code: ERROR_DEVICE_REMOVED. Your marshaler, written three years ago, checks for ERROR_SUCCESS and maps everything else to a generic COMException. That new error code gets folded into the generic handler — which your code assumes means 'something died'. But ERROR_DEVICE_REMOVED is recoverable. You could retry. Instead, your application drops the connection, orphans a transaction, and the user sees a spinning wheel forever.

That's a versioning nightmare dressed as a maintenance chore. Every time the native side adds, deprecates, or redefines an error code, your interop layer silently misclassifies it — unless you've explicitly surfaced each code. I have seen crews freeze their native dependency version for eighteen months purely because the interop error handling was too brittle to evolve. That's eighteen months of security patches, performance improvements, and bug fixes they couldn't take. The marshaler's silence didn't just hide errors — it locked the entire system in amber.

Most teams skip this: they map a handful of known error codes and dump the rest into a catch-all. That works until it doesn't. And when it breaks, the failure mode is subtle — not a crash, but a behavioral drift. Wrong data returned. A timeout that shouldn't exist. A resource that never gets released. The cost of this silence is deferred technical debt with compound interest. Pay it now, or pay it later with a hotfix at 2 AM.

'We didn't realize the marshaler was swallowing errors until a customer's entire dataset got shifted by one bit. 14 petabytes of telemetry, silently wrong for six months.'

— Platform engineer, large-scale observability provider (paraphrased from a private postmortem)

The next time someone says 'we'll handle errors in the next sprint,' remember that anecdote. Silent interop errors don't announce themselves. They compound. And in Native AOT, where the tooling for diagnosing them is leaner, the consequences echo louder. Your job isn't to make the marshaler happy — it's to make the failure visible. Every error code you surface is a landmine you've defused before anyone steps on it. That's not overhead. That's insurance.

Mini-FAQ: Common Questions About Interop Errors in AOT

Can I use exceptions across native boundaries?

Technically? Yes. Practically? Don't. C# exceptions crossing into C or Rust code — that seam blows out quietly. The native runtime has no idea what a System.Exception is. It sees corrupted memory or a stack that doesn't unwind correctly. I've watched teams spend three days debugging a crash that turned out to be a managed InvalidOperationException that the native side swallowed whole. Instead: map your errors to HRESULTs, errno codes, or a custom struct. Return the numeric code, then let your managed wrapper rehydrate it into something meaningful. The catch is — you must check that return value every call. One unchecked call and you're back to silent corruption.

Does async interop make error handling harder?

Yes. And not by a little. The problem is lifetime: a Task that faults after the native caller has already returned? That error evaporates. The pointer you passed as state? Freed. The result structure? Garbage. What usually breaks opening is the completion callback — you registered a delegate expecting it to fire synchronously, but the runtime decides to schedule it on a threadpool thread. The native code has already released the buffer. We fixed this by keeping a small ConcurrentDictionary mapping native-opaque handles to pending tasks. On completion — success or failure — we signal the task and set an error code on the handle before the native side sees it. That hurts performance by about 8%, but it beats silent data loss.

‘Async interop in AOT turns every error into a ghost — you never see it, but it still haunts your data.’

— Lead engineer, output interop incident postmortem

What about struct marshaling and error codes?

Most teams skip this: they marshal a struct with no error field. A Point { X, Y } or Result { Value }. Then the native side fails, but it has to write something into that struct — so it writes zeroes. Zero is a valid coordinate. Zero is a valid result. Your code proceeds merrily. Wrong order: put an int Status as the first field of every marshaled struct. Always. Check it before you touch anything else. The trade-off is layout compatibility — some C libraries expect a specific field order. You'll need a shim struct on the native side that mirrors yours. Annoying. But losing a day to debugging 'why is this value 0' when the real answer is 'because the call failed' — that costs more.

One more thing on structs: bool fields. Native AOT's marshaler interprets them as 4-byte Windows BOOL by default. Your C code wrote a 1-byte _Bool. Mismatch. The error code you thought you read? Actually just garbage bytes. Explicitly annotate with [MarshalAs(UnmanagedType.U1)]. Every time. Or use byte instead. I have seen this exact bug in three assembly systems this year alone. Don't become the fourth.

Recommendation: What to Do Next, Without the Hype

For new projects: use source-generated marshaling

Stop picking fights you don't need. If you're starting fresh on .NET 8 or later, the compiler's source-generated interop—LibraryImport over DllImport—should be your default. No reflection, no runtime guessing, and crucially: error handling that stays visible in the generated code. Native AOT strips IL stubs, but source generators bake the marshaling logic into your assembly. That kills whole classes of silent failures before they ship. I have seen teams waste two sprints hunting a crash that boiled down to a mismatched UnmanagedType—something source-gen would have caught at compile time. The catch: you must annotate every parameter and return type explicitly. That feels tedious. It's also the only way to guarantee the error surfaces during build, not at 3 AM in assembly.

For existing interop: wrap calls with error-checking helpers

Refactoring fifty P/Invoke declarations isn't realistic—not by Friday. What works: a thin wrapper layer that checks Marshal.GetLastPInvokeError() or your platform's equivalent immediately after every native call. Sounds trivial. Most teams skip this: they assume the interop marshaler propagates errors. It doesn't—not in Native AOT. The runtime swallows HRESULT values, ignores errno, and returns default structs like nothing happened. Wrap each call in a try block with a custom InteropResult<T> type. Return the error code explicitly. Then log it. The trade-off: you add one line per call site. The payoff: you stop debugging ghosts. We fixed a recurring segmentation fault this way—turned out the native library returned E_POINTER, but the managed side just saw a null struct with no exception.

When to fall back to custom marshaling

Sometimes source-gen can't express what you need—complex unions, callbacks with state, or dynamic buffers. That's when custom ICustomMarshaler implementations become necessary. But here's the pitfall: custom marshalers run inside the runtime's interop pipeline. In Native AOT, that pipeline gets aggressively optimized—and your error-handling code often gets optimized out. The symptom: a marshaler that worked in JIT silently fails in AOT. No exception, just corrupted data. The fix: write your marshaler to return int error codes explicitly, then check them on the managed side outside the marshaler's scope. Don't throw exceptions inside a marshaler—AOT may not have the unwinding metadata to propagate them. Instead, pass errors through a side channel (a ref bool success parameter or a Span<byte> for error messages). Ugly? Yes. Honest? Absolutely. That's the price of surface-level control.

“Silent corruption beats loud crashes every time—if your goal is hiding bugs until they hit production.”

— Lead engineer on a medical-device interop layer, after two root-cause postmortems

Your next move: pick one of these three. For greenfield: source-gen, full stop. For brownfield: wrap the five most-called native functions this week, not all fifty. For the exotic interop case: write a test harness that runs under Native AOT before you trust the marshaler. Each path costs something—but silence costs more.

Share this article:

Comments (0)

No comments yet. Be the first to comment!