How to Fix: LTO strips out functions used by the trampolines resulting in a linking error
LTO can remove trampoline-referenced functions and break the final link step
When Link Time Optimization (LTO) is enabled, the linker and compiler become much more aggressive about eliminating code they believe is unused. In this Wasmtime issue, some functions are only referenced indirectly through trampolines or symbol-passing mechanisms, so the optimizer fails to see them as live. The result is a classic late-stage failure: the build compiles, but the final link step reports missing symbols.
This is easy to misdiagnose as a generic linker problem, but the real issue is symbol reachability under whole-program optimization. If a function is only used through generated trampolines, function pointers, or metadata paths that LTO cannot reliably model, it may be stripped even though it is required at runtime or during later linking.
Understanding the Root Cause
The bug appears when Wasmtime passes function symbols into trampoline-related code paths, but those functions do not look directly referenced from the optimizer’s perspective. Under normal compilation, object files preserve more boundaries and symbols survive conservatively. Under LTO, those boundaries disappear and the optimizer performs global dead-code elimination.
Technically, this happens because:
- Indirect references are harder for LTO to treat as strong roots.
- Trampoline machinery may rely on symbols being present even when there is no obvious Rust call edge.
- rustc and the linker may decide a function is unreachable and discard it before the final symbol resolution path that needs it.
- The problem is especially visible in systems code that mixes code generation, ABI-sensitive entry points, and runtime dispatch.
In other words, the function is semantically used, but not in a way the optimizer recognizes as a guaranteed live dependency.
That is why this looks like a compiler-toolchain issue, but it can still be worked around safely in Wasmtime by making symbol liveness explicit.
Step-by-Step Solution
The practical fix is to ensure that any function consumed by trampolines is treated as retained and not eligible for LTO stripping. There are several ways to do that, and the right one depends on how Wasmtime exposes the symbol.
1. Identify the stripped symbol
First, reproduce the failure with LTO enabled and capture the missing symbol name from the linker error.
cargo build --release
If your project enables LTO through Cargo profiles, inspect Cargo.toml:
[profile.release]
lto = true
codegen-units = 1
A typical failure looks like an undefined reference to a function that should have been preserved for trampoline use.
2. Mark trampoline-used functions as externally visible
If the function must exist as a stable symbol, add visibility-preserving attributes so LTO cannot discard it too aggressively.
#[no_mangle]
pub extern "C" fn my_trampoline_target(...) {
// implementation
}
If name stability is also important, extern “C” plus no_mangle helps ensure the symbol is emitted in a predictable way.
3. Create an explicit retention path
If a function is only referenced indirectly, create a direct reference that the optimizer can see. A common workaround is to store the function pointer in a static table or registration structure that is guaranteed to be retained.
type TrampolineFn = extern "C" fn(...);
extern "C" fn my_trampoline_target(...) {
// implementation
}
#[used]
static RETAIN_TRAMPOLINE_TARGET: TrampolineFn = my_trampoline_target;
The #[used] attribute tells the compiler to keep the static, which in turn keeps the referenced function reachable.
4. Prefer a Wasmtime-local workaround when possible
Because the original report suggests this is trivially worked around in Wasmtime, the safest project-level fix is usually to retain the exact functions passed into trampolines rather than disabling LTO globally.
#[used]
static TRAMPOLINE_SYMBOLS: [extern "C" fn(...); 2] = [
trampoline_target_a,
trampoline_target_b,
];
This approach is narrow, maintainable, and avoids sacrificing release optimization for the rest of the binary.
5. If needed, reduce optimization scope for the affected crate
If symbol retention attributes are not enough, isolate the problematic crate or disable LTO only where the issue occurs.
[profile.release]
lto = "thin"
[profile.release.package.wasmtime]
codegen-units = 16
Depending on your build graph, switching from full LTO to ThinLTO may reduce the aggressiveness of whole-program stripping while preserving most optimization benefits.
6. Verify the symbol is still present
After applying the workaround, inspect the binary or intermediate objects with your platform tooling, such as nm documentation or llvm-nm documentation.
nm -C target/release/your_binary | grep my_trampoline_target
If the symbol appears and the final link succeeds, the retention fix is working.
7. Recommended fix pattern
For this issue specifically, the most reliable workaround is:
- Locate all functions passed into trampoline generation or trampoline dispatch.
- Make those functions externally visible if symbol identity matters.
- Add an explicit #[used] retention path so LTO cannot treat them as dead.
- Rebuild with LTO and validate the link result.
type HostCall = extern "C" fn(...);
#[no_mangle]
pub extern "C" fn host_entry_a(...) {
// implementation
}
#[no_mangle]
pub extern "C" fn host_entry_b(...) {
// implementation
}
#[used]
static RETAIN_HOSTCALLS: [HostCall; 2] = [host_entry_a, host_entry_b];
This is usually enough to prevent the linker error without changing runtime behavior.
Common Edge Cases
- ThinLTO vs full LTO: A bug may reproduce only with full LTO. Always test both if your CI matrix varies by platform.
- Symbol mangling: If the trampoline logic expects a concrete symbol name, forgetting
#[no_mangle]can still break integration even after retention. - Static retention without external visibility:
#[used]keeps the item alive, but if another tool expects a public exported symbol, you may also needpub extern "C". - Cross-crate behavior: A function retained in one crate may still be optimized unexpectedly if the actual trampoline registration happens elsewhere and the visibility contract is incomplete.
- Platform linker differences: GNU ld, lld, and platform-native linkers do not always behave identically when LTO metadata is involved.
- Dead stripping at the linker layer: Even after rustc emits a symbol, linker garbage collection can remove sections if retention is not rooted correctly.
- Generated code paths: If trampolines are produced by macros, build scripts, or runtime codegen, it is easy to retain some targets and miss others.
FAQ
Is this definitely a Wasmtime bug?
Not necessarily. The behavior strongly suggests a toolchain or rustc/LTO reachability issue, but Wasmtime can still work around it by explicitly retaining functions used only through trampoline paths.
Why does the build fail only when LTO is enabled?
Without LTO, compilation units are optimized more conservatively and symbol boundaries are preserved longer. With whole-program optimization, the compiler can decide the function is unused and remove it before the linker needs it.
Should I disable LTO entirely?
Usually no. The better fix is to preserve only the trampoline-required symbols with #[used], extern “C”, and where needed #[no_mangle]. Disabling LTO globally should be a fallback, not the first option.
The core lesson is simple: if a function is required by trampoline dispatch but not by an obvious direct Rust call chain, make that dependency explicit. Once the symbol is rooted in a way LTO understands, the linking error disappears.