How to Fix: Cranelift: `InstBuilder::br_table` always trigger assertion failed: !self.is_sealed(block)
Cranelift InstBuilder::br_table Assertion Failed: !self.is_sealed(block) — Root Cause and Fix
This crash is not caused by br_table itself. It happens because the control-flow graph construction order is invalid for Cranelift’s frontend: one or more destination blocks used by the jump table or default branch are being referenced after they have already been sealed, or they were not declared and populated in the way Cranelift expects for SSA construction.
Table of Contents
When building IR with Cranelift’s FunctionBuilder, branches are not just jumps. They participate in incremental SSA formation. The assertion !self.is_sealed(block) means the builder is trying to add predecessor information to a block that Cranelift already considers complete. With InstBuilder::br_table, this usually surfaces when the default block or any block inside JumpTableData is sealed too early, or when the branch targets are created and emitted in an order that conflicts with Cranelift’s sealing rules.
Understanding the Root Cause
Cranelift’s frontend uses a block-sealing model to build SSA incrementally. A block is sealed once all of its predecessors are known. After that point, adding a new incoming edge is illegal, because it would invalidate the assumptions used to resolve block parameters and phi-like values.
The key detail is this: br_table introduces multiple outgoing edges at once:
- one default branch
- zero or more indexed targets stored in
JumpTableData
For each of those targets, Cranelift records a predecessor edge from the current block. If any target block is already sealed before that edge is registered, the frontend triggers the assertion.
This is why the issue often appears surprising: the error message mentions sealing, but the visible operation is just emitting a branch table. The real bug is almost always one of these:
- You sealed a target block manually too early.
- You switched to a target block, finalized part of the CFG, and later tried to branch into it through
br_table. - You built jump table targets in a way that made Cranelift think their predecessor set was already complete.
- You created blocks, but emitted or sealed them in the wrong order relative to control-flow construction.
In short, all possible predecessors for a block must be established before the block is sealed. Since br_table can add many predecessors, all of its targets must still be open to receive incoming edges at the time the instruction is emitted.
Step-by-Step Solution
The reliable fix is to structure CFG construction so that:
- Create all blocks that will participate in the branch table up front.
- Build the
JumpTableDatausing those blocks. - Emit the
br_tablebefore sealing any of its targets. - Seal blocks only after all predecessors are known.
Here is the safe pattern.
use cranelift::prelude::*;
use cranelift_frontend::{FunctionBuilder, FunctionBuilderContext, Variable};
fn build_function(builder: &mut FunctionBuilder) {
// Create all blocks first.
let entry = builder.create_block();
let case0 = builder.create_block();
let case1 = builder.create_block();
let default_block = builder.create_block();
let exit = builder.create_block();
// Enter entry block.
builder.switch_to_block(entry);
// Example index value.
let idx = builder.ins().iconst(types::I32, 0);
// Build the jump table before sealing branch targets.
let jt_data = JumpTableData::new();
let jt = builder.create_jump_table(jt_data);
// Depending on Cranelift version, populate the jump table through the
// mutable table data API expected by your version.
builder.func.jump_tables[jt].push_entry(case0);
builder.func.jump_tables[jt].push_entry(case1);
// Emit br_table while all destination blocks are still unsealed.
builder.ins().br_table(idx, default_block, jt);
// The current block has no more instructions after an unconditional terminator.
builder.seal_block(entry);
// Case 0
builder.switch_to_block(case0);
builder.ins().jump(exit, &[]);
builder.seal_block(case0);
// Case 1
builder.switch_to_block(case1);
builder.ins().jump(exit, &[]);
builder.seal_block(case1);
// Default
builder.switch_to_block(default_block);
builder.ins().jump(exit, &[]);
builder.seal_block(default_block);
// Exit
builder.switch_to_block(exit);
builder.ins().return_(&[]);
builder.seal_block(exit);
}
The exact JumpTableData mutation API can vary slightly by Cranelift version, but the sequencing rule does not change: do not seal jump-table target blocks before emitting the branch table that points to them.
If your current code seals blocks eagerly, refactor it like this:
// Wrong mental model:
// 1. create block
// 2. finish block body
// 3. seal block immediately
// 4. later add another predecessor through br_table
// Correct mental model:
// 1. create all CFG-relevant blocks
// 2. add all incoming edges
// 3. only then seal each block once predecessor discovery is complete
A more concrete before-and-after example helps.
Problematic flow:
let default_block = builder.create_block();
builder.switch_to_block(default_block);
// ... emit instructions ...
builder.seal_block(default_block); // too early
// Later:
let jt = builder.create_jump_table(...);
builder.ins().br_table(idx, default_block, jt); // assertion here
Corrected flow:
let default_block = builder.create_block();
let case0 = builder.create_block();
let case1 = builder.create_block();
let jt = builder.create_jump_table(JumpTableData::new());
builder.func.jump_tables[jt].push_entry(case0);
builder.func.jump_tables[jt].push_entry(case1);
builder.switch_to_block(current_block);
builder.ins().br_table(idx, default_block, jt);
builder.switch_to_block(default_block);
// ... emit instructions ...
builder.seal_block(default_block);
If your function has block parameters, the same rule matters even more. Cranelift uses sealing to resolve values flowing into blocks. If a late predecessor appears after sealing, block parameter resolution becomes inconsistent, so the assertion protects IR correctness.
Practical checklist
- Create all branch-table target blocks early.
- Do not call
seal_block(target)until every predecessor is known. - Emit
br_tablebefore switching into and sealing its destination blocks, unless you are certain they still have open predecessor sets. - If a block can be reached from multiple places, delay sealing until all those branches are emitted.
- Treat
JumpTableDatatargets exactly like normal branch targets for CFG and SSA purposes.
Common Edge Cases
1. Reusing a block in multiple control-flow paths
If the same block is both a normal jump target and a jump-table target, it has multiple predecessors. Sealing it after the first edge but before the rest are emitted will fail.
// case0 also receives a direct jump elsewhere
builder.ins().jump(case0, &[]);
builder.seal_block(case0); // premature if br_table also targets case0 later
2. Building blocks out of source order
Cranelift does not require lexical order to match control-flow order, but your sealing strategy must still reflect the final predecessor graph. If you emit and seal a destination block early for convenience, then wire in br_table later, the assertion is expected.
3. Default block forgotten in the sealing analysis
Developers often focus on entries inside JumpTableData and forget that the default destination is also a branch target that must remain unsealed until the branch is emitted.
4. Block parameters and value merging
If target blocks accept block parameters, adding a predecessor after sealing is even more fragile because Cranelift would need to retroactively adjust incoming values. Make sure all predecessor edges are established first.
5. Version-specific API differences
Some Cranelift versions expose slightly different helper methods around jump tables. Even if method names differ, the underlying fix stays the same: CFG edges first, sealing second.
6. Emitting instructions after a terminator
After br_table, the current block is terminated. Do not continue emitting regular instructions into that block. Switch to another block first.
builder.ins().br_table(idx, default_block, jt);
// builder.ins().iconst(types::I32, 1); // invalid after terminator
FAQ
Why does the assertion mention is_sealed(block) instead of saying my jump table is wrong?
Because the immediate failure happens in Cranelift’s SSA/frontend bookkeeping, not in jump table encoding. br_table is simply the operation that tries to add predecessor edges to blocks that were already sealed.
Do I need to avoid calling seal_block manually?
No. Manual sealing is valid and often necessary when building CFGs incrementally. The key is to call it only after all predecessors for that block are known. If your construction order is complex, delay sealing rather than doing it eagerly.
Should I create all jump-table blocks before constructing JumpTableData?
Yes, that is the safest pattern. Create the destination blocks first, populate the jump table with them, emit br_table, and only then seal those blocks once all incoming edges are established.
The core fix for this GitHub issue is simple once the model is clear: br_table targets participate in SSA like any other branch target, so they must not be sealed before the branch table adds their incoming edges. If you reorder block creation, branch emission, and sealing around that rule, the assertion goes away and the generated Cranelift IR remains valid.