logo raviqqe's logbook

Exclusive mutation of IR in Melior without runtime checks

Summary

Handles borrow their owners

A handle to anything inside a block is a Rust borrow of that block. BlockLike::argument returns a BlockArgument<'c, 'a> and BlockLike::first_operation returns an OperationRef<'c, 'a>, where 'a is a shared borrow of the block. Nested handles carry the same 'a all the way down: OperationLike::region returns a RegionRef<'c, 'a> and RegionLike::first_block returns a BlockRef<'c, 'a>.

This is what the owner lifetimes in #1000 added. Dropping or moving an owned block, region or module while a handle into it is alive is a compile error, where it was a use-after-free before.

Current state

Verified on main at acd330ab8:

As a result, mutable handles aren’t proof of exclusive access. #1015 made apply_patterns_and_fold_greedily_with_op and walk_and_apply_patterns unsafe, because both erase IR nested in an operation that other handles may still reference. The rewriter’s erasing methods are still safe, and a draft (#1014) marks them unsafe.

Why exclusive accessors are impractical

Making the _mut accessors take &mut self would make a mutable handle to one operation require an exclusive borrow of the whole block. Rust allows either many shared borrows of the block or one exclusive borrow, so code like the following stops compiling:

let argument = block.argument(0)?;              // shared borrow of `block`
let sibling = block.first_operation().unwrap(); // another shared borrow

let mut operation = block.terminator_mut().unwrap(); // would need `&mut block`

operation.set_operand(0, argument.into()); // error[E0502]: `block` is also borrowed as immutable
operation.move_after(sibling);             // same error

This is ordinary editing: pointing an operand at one of the block’s arguments, or moving an operation next to a sibling. Neither frees anything, so the other handles stay valid. But a borrow can only lock the whole block, so “editing this one operation” becomes “nothing else from this block may be alive”, which forbids far more than the edits that actually invalidate handles. The same conflict blocks splitting the block traits into shared and mutable ones.

Moving operations across owners

move_after and move_before take the anchor as an OperationRef<'c, 'a> with the same 'a as the moved operation. Owner lifetimes are covariant, so Rust shrinks the borrows of two different owners to a common lifetime and accepts the call. After the move, the moved operation belongs to the second owner, but handles to it still borrow the first one.

let first = Block::new(&[], &[]).unwrap();
first.append_operation(OperationBuilder::new("foo.moved", location).build().unwrap());
let handle = first.first_operation().unwrap();

{
    let second = Block::new(&[], &[]).unwrap();
    let anchor = second.append_operation(OperationBuilder::new("foo.anchor", location).build().unwrap());

    first.first_operation_mut().unwrap().move_after(anchor);
}

println!("{}", handle.name().as_string_ref().as_str().unwrap());

Dropping second frees the moved operation, and printing its name through handle segfaults, with or without Guard Malloc. Lifetimes alone can’t fix this, because two distinct borrows can have the same lifetime, as two contexts alive in the same scope can. Moves between owners need either the exclusive path below or a generative brand. Until then, the two functions could be marked unsafe as #1015 did for the drivers, or check at run time that both operations have the same root.

How inkwell handles it

Read on inkwell master at 04bfd21f4 (2026-11-03). I didn’t run these cases.

Its safe interface has these gaps:

GapWhereMelior counterpart
Values outlive their moduleModule::add_function(&self) returns a FunctionValue<'ctx>, and dropping the module frees its IR. Issue #343 has been open since 2022, and the maintainer agreed that the lifetime should be tied to the module.Fixed by #999 and #1000
Erasing an instruction leaves copies of its handleInstructionValue::erase_from_basic_block(self) is safe, with only a review comment about the risk.The rewriter’s erase methods
Erasing a removed instruction dereferences nullLLVM’s eraseFromParent dereferences the parent, which is null after remove_from_basic_block.Moving detached operations
Iterators fetch the next element in advanceInstructionIter and BasicBlockIter read the next element when they yield the current one, so erasing it in the loop body leaves a dangling pointer.Block and region iterators

Only the first gap has been observed at run time: the program in #343 printed <badref> for the maintainer and segfaulted for the reporter.

Melior’s stricter model is what makes exclusive accessors hard. inkwell never hits that conflict because it gave up type-level protection of IR structure, and its only remaining tool is marking frees unsafe, which it hasn’t done for instructions.

Compile-time designs

Runtime-checked designs, such as RefCell around the IR or generational indices into an arena, are out of scope. The three designs below cost nothing at run time.

1. Separate editing from freeing

This is the recommended design.

// Edits that free nothing go through shared handles.
let argument = block.argument(0)?;
block.terminator().unwrap().set_operand(0, argument.into())?;

// Freeing needs the owner exclusively, so every shared handle above is dead here.
let mut cursor = module.body_mut().cursor_mut();

while let Some(operation) = cursor.current() {
    if is_dead(operation) {
        cursor.erase_current(); // takes `&mut cursor`, so `operation` is already unusable
    } else {
        cursor.move_next();
    }
}

This makes the erasing rewriter methods and the operation-level drivers safe again, since a mutable handle from the exclusive path really is exclusive. It also fixes iteration: only the current element can be erased, through the cursor, so fetching the next one in advance becomes harmless.

Its costs:

2. Generative brands

An editing scope such as module.edit(|token, root| ...) creates a unique, invariant brand lifetime 'id, as the GhostCell paper and the ghost-cell, qcell and generativity crates do. Handles carry 'id and a borrow of the token instead of a borrow of the container. Edits take &token, and frees take &mut token.

Like design 1, this has no runtime cost. The brand also proves that two handles belong to the same tree, which fixes moves between owners and the mixing of contexts that are alive in the same scope, neither of which plain lifetimes can express. The price is a closure-shaped API and an extra lifetime in every signature.

3. Deferred freeing

Melior’s erase could unlink an operation with mlirOperationRemoveFromParent and keep it in a list on the owner, destroying it only when the owner is dropped. By then no handle can be alive, so nothing dangles.

Recommendation

  1. Close the move across owners now, either by marking move_after and move_before unsafe or by checking that both operations have the same root.
  2. Prototype the cursor of design 1 for erasure. It is self-contained and gives the unsafe erasing methods from #1014 a safe counterpart.
  3. Move the edits that free nothing to shared handles, then gate the operation-level drivers behind the exclusive path so that they can become safe again.
  4. Adopt design 2’s brands only if moves between owners or mixed contexts turn out to matter in practice.