06 — verifyModule and Debugging JIT Crashes

A JIT failure mode looks like this: lookup("main") returns a function pointer. You call it. The process crashes with SIGBUS, SIGSEGV, or — if you're unlucky — silently produces wrong output. There is no stack trace pointing back at the IR that did it. Debugging requires reproducing the bug in tools designed for native code (lldb, instruments) against memory pages that didn't exist a moment ago.

The single best defense is to verify before you JIT. cp-17 does this at the end of emitModule:

std::string verr;
raw_string_ostream os(verr);
if (verifyModule(*r.module, &os)) {
    r.error = "verifyModule failed:\n" + os.str();
    r.module.reset();
}

verifyModule catches:

  • Basic blocks without terminators (the #1 emitter bug).
  • Multiple terminators in a basic block.
  • Branches to blocks in the wrong function.
  • Type mismatches in operands (e.g., passing i32 where i64 is expected).
  • PHI nodes with the wrong number of incoming values.
  • Use-before-def on Values outside their dominator scope.
  • Function signature mismatches between caller and callee.
  • ret void from an i64-returning function (and vice versa).

Every one of those will at best cause a JIT crash and at worst quietly miscompile. Catch them at IR emission and you save days.

Reading verifier output

The verifier names the broken instruction and prints surrounding context:

Terminator found in the middle of a basic block!
label %then

When you see this, the cause is almost always:

  • You emitted a ret or br, then forgot to switch insertion point and emitted more instructions into the same now-closed block, or
  • You forgot to check GetInsertBlock()->getTerminator() before appending a fall-through branch after an if/while body that already returned.

The cure in ir_emit.cpp is the if (!b.GetInsertBlock()->getTerminator()) guard before any CreateBr/CreateRet.

When the verifier passes but the JIT still crashes

Common remaining causes:

  • Wrong calling convention between a declared extern and the host function (e.g., declaring void f(i64) but the C function takes i32). The verifier can't catch this — both sides look internally consistent. Treatment: keep signatures in one place (a header) and reference them from both the emitter and the runtime.
  • Mutated Module after JIT addModule. ORC takes ownership; subsequent edits via the stale pointer are undefined.
  • Forgetting InitializeNativeTarget()LLJITBuilder().create() will return an error like "no available targets are compatible with this triple". jit.cpp calls these once via std::call_once.

Dumping IR while debugging

mod.print(errs(), nullptr) will dump the module as text to stderr. Add it right before the verifyModule call and you can copy-paste into llc or opt to reproduce a bug outside the JIT. The text and the in-memory form round-trip exactly, so behaviour is identical.