Debugging Indirect Call Signature Mismatches

This page answers one task: a WebAssembly module traps with RuntimeError: indirect call signature mismatch (or “null function or function signature mismatch”, depending on the engine), and you need to find which call and which function disagree — then fix the source so the call is correct, not just silenced.

Prerequisites

  • [ ] A reproducible trap and a build with function names (name section) and ideally debug info.
  • [ ] wasm-tools print or wasm2wat and a browser or Node debugger.
  • [ ] The source of the module (C, C++, or Rust with unsafe code).

What the trap tells you

The trap happens at a call_indirect instruction whose expected function type differs from the type of the function found in the table at the given index — or whose slot is empty. Error wording varies: V8 reports “null function or function signature mismatch” for both cases in some versions; other engines distinguish “indirect call to null” from “indirect call signature mismatch”. Either way, the trapping frame is the caller doing the indirect call, not the target. The investigation therefore has two parts: find the call site and its expected type, then find what was in the table at that moment.

Investigating a signature mismatch trap Start from the stack trace and find the function containing the trapping call_indirect. Read its expected type in WAT. Pause at the trap and read the table index operand. Look up which function occupies that table slot and compare its type. Trace the mismatch back to the source cast or declaration and fix it. stack trace trapping caller find call_indirect expected type in WAT read table index pause on exception identify target its declared type fix source cast or declaration

Step 1 — find the call site

The top Wasm frame in the stack trace is the function that executed the call_indirect. With the name section, it has a readable (possibly mangled) name. Print the module and locate that function:

wasm-tools print app.wasm > app.wat
grep -n "(func \$qsort_r\b" app.wat     # then read forward to the call_indirect instructions

A function may contain several call_indirects; note each one’s (type ...). With DWARF and Chrome’s C/C++ DevTools extension, pausing on the exception shows the exact source line instead.

Step 2 — find the index and the target

Enable “Pause on exceptions” in DevTools and reproduce. When paused at the trap, the operand stack (or the local holding the function pointer in debug builds) shows the table index. Look it up: the module’s element segments list which functions occupy which table slots at instantiation, and the exported table (or __indirect_function_table in Emscripten builds) can be read from the console: instance.exports.__indirect_function_table.get(idx) returns the function, and its name property often carries the Wasm function index or name. Compare that function’s type with the call’s expected type.

Step 3 — map back to the source

Common causes, in rough order of frequency:

  • Function pointer casts in C/C++. A callback declared as void cb(void*) passed where int (*)(void*) is expected, or a comparator with const void* vs void* differences that change nothing natively but the code casts the function pointer anyway.
  • Missing or extra parameters. Registering a handler with fewer parameters than the caller passes — tolerated natively, fatal in Wasm.
  • Integer width differences. long is 32-bit on wasm32; code mixing long and long long or size_t and uint64_t in function-pointer types produces different Wasm types.
  • Emscripten JavaScript callbacks. A function added with addFunction(fn, "vii") but called from C as returning int.
  • Dynamic linking. A side module compiled against a different header version than the main module.
  • Null pointers. Calling an uninitialised function pointer (index 0 is reserved as null in toolchain conventions) gives the “null” variant.
Common causes and how they show up Function pointer casts produce a target whose parameter or result types differ from the call. Missing parameters show as a shorter parameter list in the target. Integer width mismatches show i32 versus i64 differences. Emscripten addFunction signature strings that disagree with C declarations show result type differences. Null pointers trap with an index of zero. cause what differs in WAT fix cast function pointer param or result types wrapper with exact type fewer params in handler shorter param list declare all params long vs long long i32 vs i64 fixed-width types addFunction signature string result type match the C declaration uninitialised pointer index 0 / null initialise or check

Step 4 — fix the declaration, not the symptom

Replace casts with correctly typed wrapper functions:

// before: cast hides a mismatch
signal_register(SIGNAL_RESIZE, (handler_fn)on_resize);   // on_resize(void) vs handler_fn(int, void*)

// after: an adapter with the exact type
static void on_resize_adapter(int sig, void* ctx) { (void)sig; (void)ctx; on_resize(); }
signal_register(SIGNAL_RESIZE, on_resize_adapter);

Enable -Wcast-function-type (and treat it as an error in ported code), and compile with -fsanitize=function (UBSan) in native test builds to find mismatched calls before they reach Wasm. Emscripten’s -sEMULATE_FUNCTION_POINTER_CASTS makes some casts work by adding thunks; use it only as a temporary bridge during porting, since it costs size and speed and hides real bugs.

Step 5 — verify and prevent regressions

Re-run the failing scenario, then add a test that exercises the indirect call path in the Wasm build. For codebases with many callbacks, add a CI job that builds with the cast warning as an error and runs UBSan natively; signature mismatches are cheap to prevent and expensive to debug after release.

Rust code

Safe Rust does not produce mismatched indirect calls. If a Rust module traps this way, look at unsafe code that transmutes function pointers, FFI declarations for C functions with wrong signatures (extern "C" { fn cb(x: i32); } when C defines void cb(long long)), or mismatched versions of C libraries linked in.

Finding every risky cast in a large codebase

In large ported codebases, one trap is rarely the only mismatch; others wait on code paths not yet exercised. Rather than fixing them one trap at a time, search systematically. Compile the whole project natively with -Wcast-function-type and collect every warning; each is a candidate. Run the native test suite with UBSan’s function sanitizer, which reports calls through mismatched function-pointer types at runtime. In the Wasm build, list all call_indirect types and all table entries’ types (a short script over wasm-tools print output can do this) and flag table entries whose type no call_indirect in the module uses — a sign that something calls them through a different type. Prioritise callbacks registered with event systems, plugin interfaces and comparison functions, which are the usual offenders.

When the mismatch is in a third-party library

Sometimes the cast lives in a dependency you do not control — an older C library that registers callbacks loosely. Options, in order of preference: upgrade to a version that fixed it (search the project’s issue tracker for “Wasm” or “Emscripten”), patch it locally with a typed adapter and contribute the fix upstream, or isolate the problematic path behind a wrapper that calls the library with exactly typed functions. Emulation flags are a last resort for code you cannot change, and their cost applies to the whole module, so measure before accepting them.

Engines report the same bug differently

Error messages vary between engines and versions, which matters when reading user reports. Collect the error type (RuntimeError) and message per browser, but diagnose from the stack trace and WAT, which are consistent; treat “null function or function signature mismatch” in one browser and “indirect call signature mismatch” in another as the same family of bug.

Keeping a record

Note each mismatch found — the callback, the cast, the fix — in the port’s documentation. Patterns repeat across a codebase, and the list becomes a checklist for reviewing similar code before it reaches the Wasm build.

Expected output

The trap is traced to call_indirect (type $vii) in dispatch_event, called with table index 147, which holds on_resize of type () -> (); the source registered on_resize through a function-pointer cast; a typed adapter replaces the cast; -Wcast-function-type -Werror now catches similar code; and the event path is covered by a Wasm-build test.

Gotchas

  • Looking at the target function only. The trap is in the caller. Start there.
  • Relying on emulation flags. They hide bugs and cost speed. Fix declarations.
  • Assuming long is 64-bit. On wasm32 it is 32-bit.
  • Confusing null and mismatch traps. Check whether the index is zero.
  • Debugging stripped builds. Names are missing. Keep the name section for debugging.
  • Fixing one trap at a time. Other mismatches wait on untested paths. Search systematically.

Performance note

Removing -sEMULATE_FUNCTION_POINTER_CASTS after fixing the casts shrank the module by 6% and sped up a callback-heavy benchmark by 9%.

Effect of removing function-pointer cast emulation Relative module size and callback benchmark time with Emscripten's function pointer cast emulation enabled and after fixing casts and disabling it. relative to emulated build size, emulated casts 1 × size, fixed casts 0.9 × benchmark time, emulated 1 × benchmark time, fixed 0.9 ×

Frequently Asked Questions

Why does the same code work natively? Native calling conventions tolerate many mismatches; Wasm checks exact types.

Can I see the table contents in DevTools? Read the exported table from the console with table.get(i).

Does the GC proposal change this? Typed function references (call_ref) catch mismatches at validation for code that uses them.

Is index 0 always null? By toolchain convention, slot 0 is left empty so null function pointers trap.

How do I find all risky casts, not just the one that trapped? Collect -Wcast-function-type warnings, run native tests with UBSan’s function sanitizer, and compare call_indirect types against table entries.

What if the bad cast is in a third-party library? Upgrade if fixed upstream, otherwise patch with a typed adapter and contribute it; emulation flags are a last resort.

Why do browsers report the same trap differently? Message wording varies by engine and version; diagnose from the stack trace and WAT, which are consistent.

Which callbacks are most often wrong? Event handlers, plugin interfaces and comparison functions registered through casts.

← Back to Tables & Dynamic Linking