Chapter 39 — The Round Trip Home
Chapter 39 — The Round Trip Home¶
Chapter 30 ended on the extern "C" façade — the shape that survives crossing a binary boundary between two compilers, and the shape every SDK in this book chose. This chapter is what happens when the thing on the other side of that boundary is the runtime you came from.
It is a near-certain assignment. The native library is working, and now somebody wants a test harness, or an internal tool, or a UI, and the fastest way to write those is the language your team already knows. So you publish a C surface and bind it from C#, and the surface barely changes from the one Chapter 30 taught you to write.
What changes is who reads your header. Not a compiler — a person, transcribing it into a second language by hand.
The declaration is written twice, and nothing compares them¶
A [DllImport] (or, on modern .NET, a [LibraryImport]) declaration is a claim about your ABI, written in C#, compiled by a C# compiler that has never seen your header and cannot see it. Your side declares the function once. Their side declares it again. Nothing anywhere checks that the two agree.
That is worth sitting with, because it is almost certainly the first time in this reader's career that a signature is not checked by anything. In C#, calling a method with the wrong argument types is a compile error before you have finished typing. Here both sides compile, both sides link, the program runs, and the disagreement shows up as a wrong number or a corrupted heap somewhere else entirely.
You have seen this exact mechanism before. Chapter 27's ODR diamond was two declarations of one struct in one program, and the linker silently picked one. This is the same failure with the safety rail removed: there is no linker here, because the two declarations are in different languages and never meet.
The big reveal: P/Invoke is not a feature that connects two type systems. It is a promise you make twice, in two languages, that nothing verifies — and the native half is the half that gets blamed.
Which sets the whole job. You cannot make the managed declaration correct from here. What you can do is publish a surface that is hard to transcribe wrongly, and that says so at runtime when somebody has.
Blittable is the word that matters¶
A blittable type has the same representation in managed and native memory, so the marshaller has nothing to translate: the bytes cross as they are, and where the value travels by address — an array, a ref or out struct — the managed object is pinned and you get a pointer straight into it. No copy, no conversion, nothing to get wrong. The list is short and worth memorizing, because it is the whole of your palette.
| Blittable | Not blittable |
|---|---|
int/uint, short/ushort, long/ulong, byte/sbyte |
bool — one byte native, and the marshaller's default is a 4-byte Windows BOOL |
float, double |
char and string — encoding is a conversion, and a conversion is a copy |
IntPtr, UIntPtr, and any pointer |
arrays of non-blittable things, and anything holding a reference |
| a struct whose fields are all blittable | a struct with one non-blittable field anywhere in it |
The reason this matters more to you than to them: a blittable struct is passed by address, so an out-parameter you write into is the caller's own memory. A non-blittable struct is copied into a temporary, your function fills in the temporary, and whether the values make it back depends on marshalling attributes the C# author has to get right. One bool in an options struct is enough to move you from the first case to the second.
So: fixed-width integers only, from <stdint.h>. int32_t, never int; int32_t for a flag, never bool. It looks pedantic in a header a C++ caller would also use, and it is the difference between a struct that cannot be misdeclared and one that can. The one type that earns an exception is size_t, because a buffer length genuinely is pointer-sized and the managed side has a pointer-sized integer to match it — nuint, or UIntPtr before C# 9. Name that spelling in the header comment, because the transcriber's reflex is int, and on 64-bit int is half a pointer.
The size field stops being politeness¶
Chapter 30 introduced a leading size field as a versioning device — a way for version two to append fields and still serve old callers. Across a P/Invoke boundary it acquires a second job, and the second one is more urgent.
typedef struct {
uint32_t size; // caller sets this to sizeof(PluginOptions)
int32_t gain;
int32_t channels;
} PluginOptions;
The managed side declares that again by hand — and the one thing you might have feared is the one thing that is safe: a C# struct is laid out sequentially already, because that is what the compiler emits for a value type, and a type whose layout is Auto cannot be marshalled at all — the marshaller refuses to compute offsets for it rather than inventing them. Nobody silently reorders your fields. Writing [StructLayout(LayoutKind.Sequential)] is still worth the line, because it says out loud what the declaration depends on and Pack lives on the same attribute — but it is not what protects you. Nothing does. Matching field order, matching field widths, matching pack: three things transcribed by hand, and the compiler on neither side is watching.
Here is what one missed field costs, from exercises/interoplab/. A caller that read an older header declares the struct without size, so it hands over eight bytes where the surface expects twelve:
==72117==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x00016d1b6068
READ of size 4 at 0x00016d1b6068 thread T0
#0 in Plugin_Create plugin.cpp:26
#1 in main main.cpp:31
Address 0x00016d1b6068 is located in stack of thread T0 at offset 40 in frame
#0 in main main.cpp:18
[32, 40) 'wrong' (line 29) <== Memory access at offset 40 overflows this variable
Not a wrong gain. A read four bytes past the end of the caller's object, in the caller's frame — and across a real boundary the caller's frame belongs to a managed process where nobody is running a sanitizer at all. The check that turns that into a returned error code is one line:
That is the field paying for itself twice: once for the version-two conversation Chapter 30 described, and once, today, for the transcription error you cannot prevent and can absolutely detect.
Strings: three lengths and one contract¶
This is where the tickets come from, and the symptom is specific enough to recognize: on Windows, text is fine for most customers and mojibake for the German and Russian ones.
The mechanism is Chapter 9's, arriving at a boundary. A managed string is UTF-16. Your char* is bytes. Somebody converts, and the only question is whether both sides agree about to what. The historical default for [DllImport] is CharSet.Ansi, and "Ansi" there is not a synonym for ASCII or UTF-8: on Windows it means the system's active code page, which is why the bug sorts customers by language — and on Linux and macOS .NET marshals the same declaration as UTF-8. One attribute, two encodings, chosen by the machine it runs on. Modern .NET spells the choice explicitly, StringMarshalling.Utf8, and the older CharSet.Unicode means UTF-16.
None of which is yours to fix. Yours is to remove the ambiguity, in the header, where the person transcribing will read it:
Encoding is part of the contract: this is UTF-8, always, on every platform.
And then to notice that a string has three different lengths, all of them correct:
"Zähler-µ𝄞" → 9 characters
14 bytes in UTF-8 ← what your buffer must hold
10 UTF-16 units ← what `string.Length` counts
A buffer sized from the wrong one of those three is, in my experience, second only to the layout mismatch as a source of interop tickets. It is why the surface reports the size it needs rather than trusting anyone to compute it.
Who frees it — and the answer that deletes the question¶
Chapter 30's rule was whoever allocates must free. On this boundary it has a sharper edge, because the managed side has several plausible-looking ways to free your memory and none of them is your allocator. Marshal.FreeHGlobal and Marshal.FreeCoTaskMem release from particular heaps; your free() releases from your C runtime's. Hand back a malloc'd string and the C# author will free it with something, confidently — and whether that is right depends on the platform and on which C runtime your DLL linked against, which is not a contract anyone can publish. When it is wrong the corruption surfaces later, in an unrelated allocation, which is Chapter 25's Finding 10 shape all over again.
Two shapes are safe. The second is better because it does not require anyone to be careful:
- You allocate, and you publish the matching
Free. Works, and now every consumer must remember to call it — the same contractThing_DisposeDataimposes in Chapter 17, with a garbage-collected language on the other end that makes forgetting feel harmless. - The caller allocates and you fill. Called with a null buffer you report the size needed; called with a small one you say so and write nothing.
Nothing you own ever crosses, so which heap frees this is not answered — it is never asked. On the managed side that call is a byte[] the GC already owns, and the interop layer becomes boring, which is the highest compliment an interop layer can be paid.
Handles, and what SafeHandle is for¶
The opaque handle you have been publishing since Chapter 30 is exactly right here, and it has a counterpart on the managed side worth knowing about, because it changes what you should document.
SafeHandle is .NET's RAII for a native handle: a wrapper that calls your release function exactly once, however many times a Dispose or a finalizer asks it to, and — the part that matters to you — one the marshaller reference-counts across a call, so the handle cannot be closed underneath a call that is still inside your library. It is the guard type of Chapter 1, written by somebody else, for your handle. What it is not is a promise that anything runs at process exit: modern .NET does not run finalizers then at all, which is why Dispose is the path that actually closes things.
Your job is to make it possible to use:
- The handle is opaque and pointer-sized.
IntPtron their side, and nothing they can dereference. Destroytolerates null, and is called exactly once. You cannot make a raw handle idempotent — after the firstdeletethere is nothing left to ask — so say so, and point at the type that enforces it for them: aSafeHandlereleases exactly once, however the release is triggered.- Say whether it is thread-safe, because a
SafeHandleprotects the handle, not the object behind it. Chapter 16's fourth question, now asked of you.
The delegate that was collected¶
Chapter 22 taught that a lambda's captures can die before the lambda is called. The managed version of that lesson bites harder, and this is the one that produces crash reports nobody can reproduce.
When C# hands you a function pointer, what it actually creates is a small native thunk owned by a delegate object. That delegate is a managed object like any other. If nothing on the managed side keeps it rooted — and a delegate passed as an argument and then forgotten is not rooted — the collector is entitled to take it whenever it likes. Your stored pointer now aims at nothing, and it fires on the next event.
What makes it vicious is the asymmetry of the evidence. On the managed side there is no warning, no exception and nothing that looks wrong; the code that "leaked" is the code that did not keep a field. The crash is native, in your library, with your name on it.
You cannot fix that from here either. You can write down the one sentence that lets them fix it:
We hold this pointer from
Plugin_SetSinkuntilPlugin_ClearSinkreturns, and not one instruction longer.
That is the entire contract, and it is what tells a managed author how long their field has to stay alive. It is the same sentence a device SDK owes you about its callback — Chapter 16's fourth question again, and Chapter 29 if the answer involves a thread of yours.
Trap: A delegate marshalled as a function pointer is rooted only while something on the managed side holds it. Passing it straight into your registration call and keeping no field compiles, runs, works for a while, and then does not.
In the wild: the other direction¶
Everything above assumes the traffic is one-way: managed code calls your function, the marshaller does the translating, and your library never touches the runtime's own API. The mirror image exists and arrives by a completely different route — a JNI library under a JVM, an extension module inside a Python interpreter, an addon in a Node process — and what separates it is not who started the process (a C# tool loading your DLL is a managed process too) but that you are handed the runtime itself: a JNIEnv*, a PyObject*, an N-API scope. You are calling into the collector's world now, and it has rules.
The material transfers almost intact. Layout, encoding, ownership and callback windows are the same four problems with the same four answers, because they are properties of the boundary rather than of who started the process. Two things do change, and they are worth naming so you know to go looking. You do not choose the thread, and the runtime may have rules about which of its APIs may be touched from where — a C++ exception reaching a JNI entry point takes the whole VM down, which is Chapter 30's nothing-escapes rule with the stakes raised again. And you do not choose when you are unloaded, so the static-teardown material in Chapter 32 applies to a shutdown you did not schedule.
Appendix G prices in-process co-residence from the other side — its Family A is you loading a runtime into a process you own rather than the other way round, and the costs are the same room — and Chapter 38 builds the queue for the case where the foreign side must reach a host you do not own. The topology this section names has its own entry there now, Family C: the runtime that got there first and loaded you, with the four prices of Family A inverted and a different, shorter list in their place.
A Rust caller of the same kind of boundary is in Chapter 30: the declarations written a second time in extern "C", the raw pointer where the header had an opaque struct, and a Drop where C# had SafeHandle — the four things nothing checks are the same four, in a language whose compiler checks everything else.
Choosing what to publish¶
| Instead of | Publish | Because |
|---|---|---|
bool |
int32_t |
the marshaller's default width for bool is not one byte |
int, long |
int32_t, int64_t |
C++ long is 64-bit on Linux and macOS, 32-bit on Windows — and C# long is always 64-bit |
a returned char* |
a caller-filled buffer plus a needed out-parameter |
it deletes the which-heap question rather than answering it |
| an enum | int32_t plus documented constants |
enum underlying type is a compiler decision |
| a struct by value with anything non-blittable in it | a blittable struct by pointer | one copy versus zero, and a marshalling attribute they must get right |
| a function-pointer type with no convention stated | the calling convention, written down | [DllImport]'s Windows default is StdCall and your C function is Cdecl; on x86 the stack pays for the difference |
| "we call you back sometimes" | a written lifetime window | it is the only thing that lets them root the delegate correctly |
Pitfalls¶
- A
boolin an exported struct. It is one byte to you and, by default, four to the marshaller. Every field after it is then at the wrong offset, and the struct still compiles on both sides. - Assuming
[StructLayout(LayoutKind.Sequential)]is what keeps the struct honest. A C#structhas sequential layout with or without it, and a type that did not could not be marshalled at all. What nothing checks is the transcription — field order, field widths, pack — so put asizefield in front and validate it on entry, because that is the only check anywhere in the system. - Returning a pointer to a
staticor a member buffer to dodge the ownership question. It works until two threads call you, or until the second call overwrites what the first returned before the caller finished marshalling it. - Assuming a "string" is a length. Three numbers, and interop bugs live in the gap between them.
- Letting an exception reach an exported function. Chapter 30 said this already; it is worse here, because the frame above yours is a runtime that will translate an unknown foreign failure into something unrecognisable, if it survives at all.
- Testing the boundary only from C++. A C++ caller shares your compiler, your
sizeof, your enum widths and your calling convention, so it agrees with you about everything the managed side might get wrong. It is a necessary test and not a sufficient one.
Key principle: "The managed declaration of my function is written by hand and checked by nothing — so I publish only blittable types, put a size field in front of every struct, and validate it on entry."
Key principle: "Nothing I allocate crosses the boundary — the caller hands me a buffer and I tell them how big it needs to be, so which heap frees it is never asked."
Key principle: "I write the callback's lifetime window into the header, because it is the only thing that tells a managed author how long to keep their delegate rooted."
Try it¶
The finished surface is exercises/interoplab/ — write your own first, in a directory of its own.
- Publish a blittable options struct with a leading
sizefield, and validate it in your create function. Then delete the validation, pass a struct declared one field short, and run it under-fsanitize=address,undefined. Predict before you run: a wrong value, or something louder? The answer is in the lab's task card, and it is worth being wrong about first. - Add a string out-parameter with the two-call protocol — null buffer for the size, then the real one. Assert all three lengths of a non-ASCII name and satisfy yourself they are all correct.
- Add a callback with a written window, then prove the window: register, pump, clear, mark the target dead, pump again, and assert nothing arrived.
- Write the C# side on paper. You cannot compile it here, and that is the exercise: transcribe your own header into
[LibraryImport]declarations by hand, then read them back against the header looking for the four things nothing would have caught. Doing that once is what makes the rest of this chapter stick, because you will have been the person who gets it wrong. - Hardest. Add a second version of the options struct with an appended field, and make one build of the library serve both callers correctly, using the size field alone to tell them apart.