core25 min studyLesson 130 in Engineering

Foreign-function boundaries: carry ownership across the ABI

Specify layout, allocation pairs, buffer validity, asynchronous retention, and cleanup before presenting a safe native wrapper.

On this page

What you’ll understand

  • Write a native-call contract covering layout, bounds, lifetime, mutation, and release responsibility.
  • Distinguish borrowed call-duration buffers from ownership transferred after return.
  • Explain why post-call length checks cannot repair a foreign function that already overwrote memory.

A foreign function interface connects components whose type systems do not automatically prove one another's assumptions. A pointer and a length are only part of the contract. The wrapper must establish which bytes are valid, who may write them, how long they remain available, which allocator releases them, and what happens on failure. The boundary is small enough to document precisely and important enough not to guess.

The ABI describes representation, not ownership

An application binary interface specifies details such as argument passing and compatible representation. It does not tell the caller whether a pointer is borrowed, retained, or consumed. Match integer widths, signedness, alignment, calling convention, and structure layout using the actual platform and library declaration. A C-compatible representation annotation is useful for appropriate structures, but it does not make an arbitrary language container, trait object, or C++ class a portable C value. Prefer opaque handles and explicit operations when representation should remain private.

For each pointer argument, document readable or writable length, initialization requirements, permitted overlap, null handling, and whether the foreign code may retain the pointer after return. A string contract must distinguish explicit byte length from a terminating zero, and encoding validation from memory bounds. Returning a pointer to a temporary creates a lifetime error even when the ABI types match. A wrapper can turn these obligations into a safer high-level API only if the foreign implementation actually honors the stated contract.

0≤nwritten≤C,nreadable≤ninitialized0\leq n_{\rm written}\leq C,\qquad n_{\rm readable}\leq n_{\rm initialized}
All quantities are integer element counts for a specified buffer type; C is writable capacity, and readable length must not exceed initialized length. Representable conversions, element-size multiplication, allocation validity, alignment, and the foreign callee's behavior must also be established.↔ Scroll wide equations horizontally.

Allocation and release must belong to one agreement

If a native library creates a handle, its documented release function is usually the appropriate partner. Do not reconstruct a Rust Box or vector from arbitrary foreign storage unless the required allocator, layout, ownership, and construction conditions are satisfied. A foreign malloc result and a language-specific owning container are not interchangeable merely because both contain a pointer. Conversely, transferring a Rust-created allocation requires a documented way to return responsibility to the matching owner exactly once. Error paths must state whether partially produced handles require cleanup.

A safe wrapper should validate returned lengths before exposing initialized values, but that check cannot undo an out-of-bounds write that already occurred inside the foreign call. Pre-call capacity guarantees and trust in the callee's memory contract remain necessary. Translate error codes into explicit outcomes and release acquired resources on every supported path. Unwinding and exception propagation across the boundary require a deliberately compatible ABI and runtime contract; do not assume a panic, C++ exception, or long jump is harmless because the normal return path was tested.

[tfirst access,tfinal access]⊆Vbuffer,Mpeak≥Minput+Moutput capacity[t_{\rm first\ access},t_{\rm final\ access}]\subseteq V_{\rm buffer},\qquad M_{\rm peak}\geq M_{\rm input}+M_{\rm output\ capacity}
The first relation requires the entire native-access interval, including asynchronous use, to fit inside the buffer's valid region. The second is a lower bound on retained backing bytes when input and output allocations coexist; it excludes other allocations and does not equate capacity with initialized output length.↔ Scroll wide equations horizontally.

An asynchronous return is not completion

A native accelerator or worker may retain a buffer after the initiating call returns. Its final completion or cancellation acknowledgment must establish that no future access can occur before the owner frees or reuses the storage. A timeout on the waiting caller is weaker evidence. In a moving managed runtime, keeping the object reachable may need to be paired with pinning, copying, or an approved handle mechanism so the native address remains meaningful. External threads and callbacks must also follow the host runtime's entry and synchronization rules.

The queue lab illustrates the number of pending operations that may retain buffers. Press Reset, set λ=4 and μ=3, predict backlog grows from four by one operation per second, then Play or Step. For a separate experiment, press Reset before setting λ=2 and μ=3; predict the restored four-job backlog drains in four seconds, then Play or Step. Multiply only by a stated per-pending-operation backing size; executing operations and shared allocations need separate accounting. The model does not represent callbacks, allocator families, pinning, or ABI layouts. Use it to ask who still owns each pending buffer and which real completion event, rather than a visual disappearance or a caller timeout, permits release.

Wrap a borrowed input and owned output

A synchronous native transform borrows a 128-byte initialized input. The caller supplies a separate 256-byte output allocation. The documented callee writes exactly 180 initialized output bytes and retains no pointers after return.

  1. Before the call, establish that the input remains readable for 128 bytes, the output writable for 256 bytes, and the regions obey the callee's non-overlap and alignment requirements. Both allocations remain live throughout the call.

  2. On a successful return, validate that the reported length 180 fits the 256-byte capacity before exposing those initialized bytes. Initialized input-plus-output data totals 128+180=308 bytes, while retained backing capacity totals at least 128+256=384 bytes.

  3. The caller retains output release responsibility through its matching allocator. The input may be released after the synchronous call only if no other reader remains; this relies on the callee's promise not to retain pointers.

The result

A sound wrapper combines pre-call validity, the foreign implementation's contract, post-call validation, and exactly-once release responsibility. Checking 180≤256 alone proves too little.

Explore the model

Interactive lab

Interactive lab · queue

Pending native work retains backing storage

Press Reset, set λ=4 and μ=3, predict one additional pending operation per second, then Play or Step. For a separate run, press Reset, set λ=2 and μ=3, predict the restored four-job backlog drains in four seconds, then Play or Step. This fluid backlog is a retention illustration, not an ABI, callback, pinning, or native-buffer-lifetime simulator.

Pending native work retains backing storageFluid backlog starts at 4 and follows max(0, 4 + (λ − μ)t). The separate M/M/1 residence-time formula assumes Poisson arrivals, exponential service and λ < μ. Current readings: Fluid backlog: 4; Net rate · λ − μ: −1 /s; M/M/1 mean residence: 1 s. All parameters are adjustable with the labeled controls below.02.557.51001.534.56Elapsed time · sFluid backlogλ < μ
Fluid backlogCurrent time
Fluid backlog
4
Net rate · λ − μ
−1 /s
M/M/1 mean residence
1 s
t = 0 s

Fluid backlog starts at 4 and follows max(0, 4 + (λ − μ)t). The separate M/M/1 residence-time formula assumes Poisson arrivals, exponential service and λ < μ. Parameter changes pause playback and recompute the model at the current elapsed time. Reset restores both parameters and returns time to zero. Playback starts paused. Adjust either parameter, then choose Play or Step.

Look forBuffer release depends on the actual final native-access event, while arrival-service balance determines how many valid buffers can remain pending.

Check your understanding

A caller times out while a native worker still holds a raw pointer to its input. What justifies freeing that input?

Try it yourself

A foreign function reports that it wrote 300 bytes into a supplied 256-byte output buffer. Why is rejecting the returned length necessary but insufficient to restore memory safety?

Take a moment to make a prediction or work it out on paper. Open a hint when you need one.

Give me a hint

Separate exposing an invalid result from damage that may already have occurred during the call.

Compare with the solution

Rejecting 300 prevents the wrapper from presenting a 300-byte initialized result backed by only 256 bytes. It cannot undo any overwrite that the foreign function may already have performed beyond capacity. A sound safe wrapper requires a callee contract that prevents such writes, appropriate pre-call sizing, and a matching ownership and release agreement; post-call validation is only one layer.

Take it with you

  • ABI compatibility does not establish ownership, initialization, or lifetime.
  • Pair allocation and release through the documented allocator and handle contract.
  • Asynchronous buffers remain valid until final native access is conclusively finished.

Read further

Original sources and deeper explanations to follow at your own pace.

Optional progress, saved in this browser when available. Revisit any lesson at any time.

Lesson not marked complete.