Skip to content

19. A Better Abort: Reporting Assertion Failures on stderr

Mountain goats in space helmets on Titania, a moon of Uranus

What you will understand: how to replace the lowering of MLIR's cf.assert with a small custom pass, so a failed runtime check reports its message on standard error and never loses it. Chapter 14 closed an out-of-bounds read with a runtime shape check, but found that its explanation vanished whenever stdout was a pipe or a file. This chapter fixes that, shows the fix working in three ways of capturing output, and then does what the earlier chapters' lessons demand: attacks it. Two real bugs surfaced while hardening the pass (an invalid-IR case and a symbol collision), each reproduced with its actual error text. All code, tests and outputs are embedded from the repository.

What you need to know first: Chapter 14's runtime check (cf.assert, abort on shape mismatch) and the stdout-buffering problem it found. New ground: file descriptors, the write system call, and writing an MLIR rewrite pattern that splits blocks.

Compile and run

Every command, listing and output on this page is reproduced by the commands in Reproducing this chapter at the bottom of the page, which also lists what must be built first. The chain of builds is in Getting Started.

Why this page shows FAIL lines, and why that is good

Parts of this page come from negative controls: the same tests run against an older build, or against deliberately broken code (a "mutation"). In those runs a FAIL is the expected, wanted result: it means a test noticed the problem, which is how we know the tests are worth anything. What would be wrong is the opposite, a deliberately broken build that passes everything. The baseline (the unmodified current build) must always show every test passing, and it does. Each script that does this says so in its own header and prints a reminder at the top of its output.

Primer: why a message can vanish

A running Unix program has three standard file descriptors: 0 (input), 1 (standard output, "stdout") and 2 (standard error, "stderr"). A program that prints with C's puts writes to the stdio library's buffered stdout stream: the text is held in memory and only sent to descriptor 1 when the buffer fills, is flushed, or the program exits normally. When stdout is a terminal the library flushes at each newline, so you see the text immediately. When stdout is a pipe or a file it flushes only when the buffer fills or at exit.

abort() ends the process at once, by raising SIGABRT. It does not flush stdio buffers. So the sequence "print a message with puts, then abort()" loses the message whenever stdout is not a terminal, which is exactly what MLIR's own lowering of cf.assert does (Chapter 14 read the lowered code: call @puts, then call @abort). There are two separate flaws: the buffering, and the stream (error messages belong on stderr, not stdout).

The write(fd, buffer, length) function is different in kind. It is a thin wrapper over the operating system's write call: there is no buffer to flush. Once write returns, the bytes have been handed to the kernel, so a following abort() cannot lose them. Writing to descriptor 2 with write fixes both flaws at once.

The problem, shown side by side

demo.sh builds the same mismatched call (a 2x3 plus a 1x2 mg.add, Chapter 13's harness_mismatch.c) two ways, Chapter 14's default lowering and this chapter's pass, and observes each under three ways of capturing output. The complete, unedited output (demo_out.txt):

#!/bin/sh
# The same runtime shape mismatch, built two ways, observed under three ways of capturing output. Output: demo_out.txt
# Needs ../../part14/code/build.sh (default lowering) and ./build.sh (this chapter's pass).
HERE=$(cd "$(dirname "$0")" && pwd); D=$HERE/../..; W=$HERE/work/demo; mkdir -p $W
OLD=$D/part14/code/build/mg-opt; NEW=$HERE/build/mg-opt
LOW='--lower-affine --convert-scf-to-cf --convert-arith-to-llvm --finalize-memref-to-llvm --convert-func-to-llvm --reconcile-unrealized-casts'
LOWNEW='--lower-affine --convert-scf-to-cf --mg-lower-assert-to-stderr --convert-arith-to-llvm --finalize-memref-to-llvm --convert-func-to-llvm --reconcile-unrealized-casts'
SRC=$D/part13/code/both.mlir; H=$D/part13/code/harness_mismatch.c
build() {  # name mg-opt lowering
  $2 $SRC --convert-mg-to-affine $3 -o $W/$1.mlir && mlir-translate-18 --mlir-to-llvmir $W/$1.mlir -o $W/$1.ll &&
  clang-18 -c $W/$1.ll -o $W/$1.o 2>/dev/null && clang-18 $H $W/$1.o -o $W/$1.exe
}
build old $OLD "$LOW"; build new $NEW "$LOWNEW"
for v in old new; do
  case $v in old) label="Chapter 14 lowering (cf.assert -> puts + abort)";; new) label="Chapter 19 pass (cf.assert -> write(2, ...) + abort)";; esac
  echo "################ $label"
  echo "--- stdout is a PIPE, stderr shown:    (command: ./$v.exe 2>&1 | cat)"
  $W/$v.exe 2>&1 | cat
  echo "--- stdout is a FILE, stderr discarded (command: ./$v.exe > out.txt 2>/dev/null; cat out.txt)"
  $W/$v.exe > $W/$v.out.txt 2>/dev/null; cat $W/$v.out.txt; echo "(stdout file holds $(wc -c < $W/$v.out.txt) bytes)"
  echo "--- stdout discarded, stderr only:      (command: ./$v.exe 2>&1 >/dev/null)"
  $W/$v.exe 2>&1 >/dev/null
  echo "--- exit status of the program itself: $($W/$v.exe >/dev/null 2>&1; echo $?)"
  echo
done
################ Chapter 14 lowering (cf.assert -> puts + abort)
--- stdout is a PIPE, stderr shown:    (command: ./old.exe 2>&1 | cat)
--- stdout is a FILE, stderr discarded (command: ./old.exe > out.txt 2>/dev/null; cat out.txt)
(stdout file holds 0 bytes)
--- stdout discarded, stderr only:      (command: ./old.exe 2>&1 >/dev/null)
--- exit status of the program itself: 134

################ Chapter 19 pass (cf.assert -> write(2, ...) + abort)
--- stdout is a PIPE, stderr shown:    (command: ./new.exe 2>&1 | cat)
mg.add: operand shapes differ at runtime in dimension 0
--- stdout is a FILE, stderr discarded (command: ./new.exe > out.txt 2>/dev/null; cat out.txt)
(stdout file holds 0 bytes)
--- stdout discarded, stderr only:      (command: ./new.exe 2>&1 >/dev/null)
mg.add: operand shapes differ at runtime in dimension 0
--- exit status of the program itself: 134

The old lowering prints nothing under any of the three captures shown (stdout is a pipe in the first, a file in the second, and the stderr-only view in the third finds nothing because the message was on stdout and never flushed). The new pass puts the message on stderr in two of the three; in the middle case stderr was deliberately discarded, so an empty stdout file is the correct result. Both builds still die with exit status 134 (SIGABRT): the safety property never changed, only whether the explanation survives.

Design

What the pass must do for each cf.assert %cond, "message": if %cond is true, continue; if false, write the message to descriptor 2, then abort. In MLIR terms that is a conditional branch and a new block:

    cf.cond_br %cond, ^continue, ^fail
  ^fail:
    llvm.call @write(2, <message>, <length>)
    llvm.call @abort()
    llvm.unreachable

The message becomes a private constant string global in the module, and llvm.mlir.addressof takes its address. The pass uses the llvm dialect's operations directly inside the func.func body; the book's usual lowering passes later convert everything else around them, and llvm.call operations are left alone.

Two alternatives were considered and not built: calling fflush(NULL) between the puts and the abort, which would fix the buffering but leave the message on stdout; and calling fputs on the C library's stderr object, which needs a platform-specific global symbol (the name differs between systems). The write(2, ...) route needs no FILE* and no platform-specific symbol beyond write and abort themselves.

The implementation

The whole pass, AssertToStderr.cpp, in sections. The header comment and includes:

// A replacement lowering for cf.assert that reports the message on STDERR before aborting.
//
// MLIR's own cf.assert lowering calls puts() (stdout) then abort(); abort() does not flush stdio buffers, so when stdout is
// a pipe or a file the message is lost (Chapter 14). This pass rewrites each cf.assert, BEFORE the normal lowering would see it, into
//     cf.cond_br %cond, ^continue, ^fail
//   ^fail:  llvm.call @write(2, "message\n", len) ; llvm.call @abort() ; llvm.unreachable
// write(2, ...) is a direct system call wrapper: unbuffered, no FILE*, so nothing is left unflushed when abort() runs.
#include "mlir/Dialect/ControlFlow/IR/ControlFlowOps.h"
#include "mlir/Dialect/Func/IR/FuncOps.h"
#include "mlir/Dialect/LLVMIR/LLVMDialect.h"
#include "mlir/IR/PatternMatch.h"
#include "mlir/Pass/Pass.h"
#include "mlir/Transforms/GreedyPatternRewriteDriver.h"
#include "llvm/ADT/StringExtras.h"

using namespace mlir;

A helper that declares write and abort once, at module level, as external llvm.funcs:

/// Finds or creates a module-level llvm.func declaration.
static LLVM::LLVMFuncOp getOrDeclare(ModuleOp module, StringRef name, Type result, ArrayRef<Type> args) {
  if (auto f = module.lookupSymbol<LLVM::LLVMFuncOp>(name))
    return f;
  OpBuilder b(module.getBodyRegion());
  return b.create<LLVM::LLVMFuncOp>(module.getLoc(), name, LLVM::LLVMFunctionType::get(result, args));
}

The rewrite pattern. It begins with a guard (explained below), then makes the message global, then rewrites the control flow:

struct AssertToStderrPattern : public OpRewritePattern<cf::AssertOp> {
  using OpRewritePattern::OpRewritePattern;

  LogicalResult matchAndRewrite(cf::AssertOp op, PatternRewriter &rewriter) const override {
    // Only rewrite asserts that sit directly in a function body, where several blocks are legal. An assert nested inside a
    // region that requires a single block (an affine.for or scf.for body, for example) cannot be split into blocks without
    // producing invalid IR, so it is left for MLIR's default lowering. Running this pass AFTER --convert-scf-to-cf, when no
    // structured control flow is left, makes every assert eligible.
    if (!isa<func::FuncOp>(op->getParentOp()))
      return rewriter.notifyMatchFailure(op, "assert is not directly inside a func.func");
    auto module = op->getParentOfType<ModuleOp>();
    Location loc = op.getLoc();
    MLIRContext *ctx = rewriter.getContext();
    Type i8 = rewriter.getI8Type(), i32 = rewriter.getI32Type(), i64 = rewriter.getI64Type();
    Type ptr = LLVM::LLVMPointerType::get(ctx);

    // One private constant string global per assert: the message plus a newline.
    std::string text = op.getMsg().str() + "\n";
    // Pick a global name no existing symbol uses, so the pass can run more than once on a module (a first run leaves a nested assert,
    // a second run after --convert-scf-to-cf rewrites it) without "redefinition of symbol" errors.
    std::string name;
    for (unsigned k = 0;; ++k) {
      name = "mg_assert_stderr_msg_" + std::to_string(k);
      if (!module.lookupSymbol(name))
        break;
    }
    {
      OpBuilder::InsertionGuard g(rewriter);
      rewriter.setInsertionPointToStart(module.getBody());
      rewriter.create<LLVM::GlobalOp>(loc, LLVM::LLVMArrayType::get(i8, text.size()), /*isConstant=*/true,
                                      LLVM::Linkage::Private, name, rewriter.getStringAttr(text));
    }
    LLVM::LLVMFuncOp writeFn = getOrDeclare(module, "write", i64, {i32, ptr, i64});
    LLVM::LLVMFuncOp abortFn = getOrDeclare(module, "abort", LLVM::LLVMVoidType::get(ctx), {});

    // Split the block at the assert: everything after it moves to ^continue.
    Block *here = op->getBlock();
    Block *cont = rewriter.splitBlock(here, Block::iterator(op));
    Block *fail = rewriter.createBlock(cont);   // placed between `here` and `cont`

    rewriter.setInsertionPointToEnd(fail);
    Value fd = rewriter.create<LLVM::ConstantOp>(loc, i32, rewriter.getI32IntegerAttr(2));
    Value len = rewriter.create<LLVM::ConstantOp>(loc, i64, rewriter.getI64IntegerAttr(text.size()));
    Value msg = rewriter.create<LLVM::AddressOfOp>(loc, ptr, name);
    rewriter.create<LLVM::CallOp>(loc, writeFn, ValueRange{fd, msg, len});
    rewriter.create<LLVM::CallOp>(loc, abortFn, ValueRange{});
    rewriter.create<LLVM::UnreachableOp>(loc);

    rewriter.setInsertionPointToEnd(here);
    rewriter.create<cf::CondBranchOp>(loc, op.getArg(), cont, ValueRange{}, fail, ValueRange{});
    rewriter.eraseOp(op);
    return success();
  }
};

Reading the middle of it: rewriter.splitBlock(here, Block::iterator(op)) cuts the block at the assert, leaving everything after the assert in a new block cont; rewriter.createBlock(cont) creates the failing block fail just before it; the failing block gets the constants, the addressof, the two calls and llvm.unreachable; and the original block now ends with a cf.cond_br on the assert's condition. Finally the assert itself is erased. The global's name is chosen by searching for the first mg_assert_stderr_msg_<k> not already defined in the module (a fix described below), and the message gets a trailing newline appended before it is stored.

The pass wrapper and its registration, which follow the exact pattern Chapter 4's pass used:

struct MgAssertToStderrPass : public PassWrapper<MgAssertToStderrPass, OperationPass<ModuleOp>> {
  MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(MgAssertToStderrPass)
  StringRef getArgument() const final { return "mg-lower-assert-to-stderr"; }
  StringRef getDescription() const final {
    return "Rewrite cf.assert into a branch that write()s its message to fd 2 and aborts (MLIR's own lowering uses buffered stdout)";
  }
  void getDependentDialects(DialectRegistry &registry) const override {
    registry.insert<cf::ControlFlowDialect, LLVM::LLVMDialect>();
  }
  void runOnOperation() override {
    RewritePatternSet patterns(&getContext());
    patterns.add<AssertToStderrPattern>(&getContext());
    if (failed(applyPatternsAndFoldGreedily(getOperation(), std::move(patterns))))
      signalPassFailure();
  }
};
} // namespace

namespace mg {
void registerAssertToStderrPass() { PassRegistration<MgAssertToStderrPass>(); }
} // namespace mg

Wiring it into mg-opt and the build is a small change, shown here in full (chapter19.diff: 38 lines against Chapter 14's mg-opt.cpp and CMakeLists.txt):

--- ../../part14/code/mg-opt.cpp    2026-10-01 23:01:57.030167769 +0000
+++ mg-opt.cpp  2026-10-02 01:50:44.084955341 +0000
@@ -18,6 +18,7 @@

 namespace mg {
 void registerConvertMgToAffinePass();
+void registerAssertToStderrPass();
 void registerBufferizableOpInterfaceExternalModels(mlir::DialectRegistry &registry);
 }

@@ -26,6 +27,7 @@
   mlir::registerCSEPass();
   mlir::registerReconcileUnrealizedCastsPass();
   mg::registerConvertMgToAffinePass();
+  mg::registerAssertToStderrPass();
   mlir::bufferization::registerBufferizationPasses();
   mlir::registerConversionPasses();
   mlir::affine::registerAffinePasses();
--- ../../part14/code/CMakeLists.txt    2026-10-01 23:01:57.032008443 +0000
+++ CMakeLists.txt  2026-10-02 01:50:44.084972667 +0000
@@ -42,6 +42,9 @@
   MLIRFuncTransforms
 )

+add_library(MgAssertToStderr lib/AssertToStderr.cpp)
+target_link_libraries(MgAssertToStderr PUBLIC MLIRIR MLIRPass MLIRControlFlowDialect MLIRLLVMDialect MLIRTransformUtils)
+
 add_library(MgBufferizableOpInterfaceImpl lib/MgBufferizableOpInterfaceImpl.cpp)
 add_dependencies(MgBufferizableOpInterfaceImpl MgOpsIncGen)
 target_link_libraries(MgBufferizableOpInterfaceImpl PUBLIC
@@ -59,6 +62,7 @@
 add_executable(mg-opt tools/mg-opt.cpp)
 target_link_libraries(mg-opt PRIVATE
   MgDialect
+  MgAssertToStderr
   MLIRControlFlowDialect
   MgLowerToAffine
   MgBufferizableOpInterfaceImpl

build.sh assembles the compiler from the earlier chapters' files plus this chapter's three:

#!/bin/sh
# Rebuild mg-opt with this chapter's pass: the earlier chapters' latest files plus this chapter's three. Produces ./build/mg-opt.
# Needs mlir-18-tools, libmlir-18-dev, llvm-18-dev, cmake, make.
set -e
HERE=$(cd "$(dirname "$0")" && pwd); D=$HERE/../..; T=$HERE/tree
mkdir -p $T/include/mg $T/lib $T/tools
cp $HERE/CMakeLists.txt $T/
cp $D/part3/code/MgDialect.td $D/part3/code/MgOps.td $D/part3/code/MgDialect.h $D/part3/code/MgOps.h $T/include/mg/
cp $D/part14/code/DynamicShapes.h $T/include/mg/
cp $D/part13/code/MgDialect.cpp $T/lib/
cp $D/part14/code/LowerToAffine.cpp $D/part14/code/MgBufferizableOpInterfaceImpl.cpp $T/lib/
cp $HERE/AssertToStderr.cpp $T/lib/
cp $HERE/mg-opt.cpp $T/tools/
mkdir -p $HERE/build/include/mg && cd $HERE/build
cmake $T -DMLIR_DIR=/usr/lib/llvm-18/lib/cmake/mlir -DLLVM_DIR=/usr/lib/llvm-18/lib/cmake/llvm \
  -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_ASSERTIONS=OFF > cmake_out.txt 2>&1
make -j8 2>&1 | tail -2

What the pass produces

On the dynamic add, after --convert-mg-to-affine --mg-lower-assert-to-stderr (show/dyn_after_pass.mlir, first 30 lines). The two message globals and the two declarations come first, then the function: each assert is now a cf.cond_br whose failing block writes and aborts, and the original computation continues in the last block:

module {
  llvm.mlir.global private constant @mg_assert_stderr_msg_1("mg.add: operand shapes differ at runtime in dimension 0\0A") {addr_space = 0 : i32}
  llvm.func @abort()
  llvm.func @write(i32, !llvm.ptr, i64) -> i64
  llvm.mlir.global private constant @mg_assert_stderr_msg_0("mg.add: operand shapes differ at runtime in dimension 1\0A") {addr_space = 0 : i32}
  func.func @add_dyn(%arg0: memref<?x?xf64>, %arg1: memref<?x?xf64>) -> memref<?x?xf64> {
    %0 = llvm.mlir.constant(56 : i64) : i64
    %1 = llvm.mlir.constant(2 : i32) : i32
    %c1 = arith.constant 1 : index
    %c0 = arith.constant 0 : index
    %dim = memref.dim %arg0, %c0 : memref<?x?xf64>
    %dim_0 = memref.dim %arg1, %c0 : memref<?x?xf64>
    %2 = arith.cmpi eq, %dim, %dim_0 : index
    cf.cond_br %2, ^bb2, ^bb1
  ^bb1:  // pred: ^bb0
    %3 = llvm.mlir.addressof @mg_assert_stderr_msg_1 : !llvm.ptr
    %4 = llvm.call @write(%1, %3, %0) : (i32, !llvm.ptr, i64) -> i64
    llvm.call @abort() : () -> ()
    llvm.unreachable
  ^bb2:  // pred: ^bb0
    %dim_1 = memref.dim %arg0, %c1 : memref<?x?xf64>
    %dim_2 = memref.dim %arg1, %c1 : memref<?x?xf64>
    %5 = arith.cmpi eq, %dim_1, %dim_2 : index
    cf.cond_br %5, ^bb4, ^bb3
  ^bb3:  // pred: ^bb2
    %6 = llvm.mlir.addressof @mg_assert_stderr_msg_0 : !llvm.ptr
    %7 = llvm.call @write(%1, %6, %0) : (i32, !llvm.ptr, i64) -> i64
    llvm.call @abort() : () -> ()
    llvm.unreachable
  ^bb4:  // pred: ^bb2

(The _1/_0 numbering is the order the rewrite driver visited the asserts, which is the reverse of source order; it is cosmetic.) No cf.assert remains. The message is exactly what was asked for, byte for byte: a test message containing a double quote, a percent sign and non-ASCII text (bad "quote" 100% done, naïve café) arrives as these bytes on descriptor 2, with the UTF-8 sequences intact (303 257 is ï, 303 251 is é) and exactly one trailing newline:

0000000   b   a   d       "   q   u   o   t   e   "       1   0   0   %
0000020       d   o   n   e   ,       n   a 303 257   v   e       c   a
0000040   f 303 251  \n
0000044

Where the pass must sit in the pipeline

The pass has to run before MLIR's own lowering sees the asserts, because --convert-func-to-llvm consumes cf.assert itself (Chapter 14 found that lowering already handles it). Checked directly by putting the standard lowering first (show/ordering_wrong.txt):

cf.assert left after the standard lowering: 0
calls to puts (the stdout lowering): 2
calls to write (this pass): 0

The standard lowering took both asserts (two puts calls) and the pass then found nothing to rewrite (zero write calls), with no warning. A user who adds the pass at the wrong point gets the old behavior silently. The tests below always put it before the conversion passes; the test suite's %lower-with-stderr substitution is the one correct ordering used in this book.

Two bugs found while hardening it

The first version worked on the demo and passed its first tests. Attacking it with unusual inputs found two real problems, each fixed and each now pinned by a test. The sources of the broken versions are the mutation builds in the failure-injection section below, so the error text here is reproduced from those builds, not remembered.

1. An assert inside a loop made invalid IR

An affine.for body is a region that must have exactly one block. The first version of the pattern split blocks wherever it found a cf.assert, so an assert inside a loop body produced IR with several blocks in a single-block region. The input (nested.mlir, a loop that asserts every element is not NaN):

func.func @nanloop(%a: memref<?xf64>, %n: index) {
  affine.for %i = 0 to %n {
    %v = affine.load %a[%i] : memref<?xf64>
    %ok = arith.cmpf oeq, %v, %v : f64
    cf.assert %ok, "value is NaN"
  }
  return
}

and the error the unguarded pass produced on it (bugs/nested_without_guard.txt):

nested.mlir:2:3: error: 'affine.for' op expects region #0 to have 0 or 1 blocks
  affine.for %i = 0 to %n {
  ^
nested.mlir:2:3: note: see current operation: 

The fix is the guard at the top of the pattern: only rewrite an assert whose parent is a func.func, where multiple blocks are legal. Others are left for MLIR's default lowering. Placing the pass after --lower-affine --convert-scf-to-cf, when no structured loops remain, makes every assert eligible; both placements are tested, and the nested program was run for real (clean data survives, a NaN at index 3 aborts with value is NaN on stderr).

2. Running the pass twice collided on global names

The first version numbered its message globals from a counter that restarted at zero on each run. A module with a top-level assert and a nested one, run through the pass, then flattened, then through the pass again, rewrote the top-level assert on the first run (global _0) and the nested one on the second, reusing the name _0 (bugs/twice_without_unique_names.txt):

twice.mlir:2:3: error: redefinition of symbol named 'mg_assert_stderr_msg_0'
  cf.assert %ok, "top-level check"
  ^
twice.mlir:2:3: note: see current operation: 

The fix is in the code shown above: search the module for the first unused name instead of counting. The input and the final names (twice_out.mlir):

func.func @f(%a: memref<?xf64>, %n: index, %ok: i1) {
  cf.assert %ok, "top-level check"
  affine.for %i = 0 to %n {
    %v = affine.load %a[%i] : memref<?xf64>
    %fine = arith.cmpf oeq, %v, %v : f64
    cf.assert %fine, "value is NaN"
  }
  return
}
  llvm.mlir.global private constant @mg_assert_stderr_msg_0("top-level check\0A")
  llvm.mlir.global private constant @mg_assert_stderr_msg_1("value is NaN\0A")

The tests

Eight new tests, in test/assert-stderr/, bring the suite to 52 (run_out_52.txt below). They are shown in full; each header says what it pins down. The shape of the rewrite:

// Chapter 19: --mg-lower-assert-to-stderr turns every cf.assert in a function body into a branch whose failing side write()s the
// message to file descriptor 2 and aborts. No cf.assert is left; the two messages become two private constant strings.
// RUN: %mg-opt %inputs/dyn.mlir --convert-mg-to-affine --mg-lower-assert-to-stderr | %FileCheck %s
// CHECK-DAG: llvm.mlir.global private constant @mg_assert_stderr_msg_{{[0-9]+}}("mg.add: operand shapes differ at runtime in dimension 0\0A")
// CHECK-DAG: llvm.mlir.global private constant @mg_assert_stderr_msg_{{[0-9]+}}("mg.add: operand shapes differ at runtime in dimension 1\0A")
// CHECK-DAG: llvm.func @write(i32, !llvm.ptr, i64) -> i64
// CHECK-DAG: llvm.func @abort()
// CHECK-NOT: cf.assert
// CHECK: cf.cond_br
// CHECK: llvm.call @write(%{{.*}}, %{{.*}}, %{{.*}}) : (i32, !llvm.ptr, i64) -> i64
// CHECK-NEXT: llvm.call @abort() : () -> ()
// CHECK-NEXT: llvm.unreachable

That a program with no assertions passes through unchanged:

// Chapter 19: a program with no cf.assert (any static program) passes through the pass byte for byte.
// RUN: %mg-opt %inputs/static_add.mlir --convert-mg-to-affine -o %t.before.mlir
// RUN: %mg-opt %t.before.mlir --mg-lower-assert-to-stderr -o %t.after.mlir
// RUN: cmp %t.before.mlir %t.after.mlir

The reason the pass exists: the message reaches stderr, on both lowering paths, with stdout discarded:

// Chapter 19: the reason this pass exists. A runtime shape mismatch aborts, AND its message reaches STDERR even though stdout is
// discarded (Chapter 14 found the default lowering loses the message whenever stdout is not a terminal). Both lowering paths.
// RUN: %mg-opt %inputs/dyn.mlir --convert-mg-to-affine %lower-with-stderr -o %t.a.mlir
// RUN: %mlir-translate --mlir-to-llvmir %t.a.mlir -o %t.a.ll
// RUN: %clang -c %t.a.ll -o %t.a.o
// RUN: %clang %inputs/mismatch_harness.c %t.a.o -o %t.a.exe
// RUN: %not --crash %t.a.exe
// RUN: %not --crash %t.a.exe 2>&1 >/dev/null | %FileCheck %s
// RUN: %mg-opt %inputs/dyn.mlir --one-shot-bufferize="bufferize-function-boundaries" -o %t.b0.mlir
// RUN: %mg-opt %t.b0.mlir %lower-with-stderr -o %t.b.mlir
// RUN: %mlir-translate --mlir-to-llvmir %t.b.mlir -o %t.b.ll
// RUN: %clang -c %t.b.ll -o %t.b.o
// RUN: %clang %inputs/mismatch_harness.c %t.b.o -o %t.b.exe
// RUN: %not --crash %t.b.exe
// RUN: %not --crash %t.b.exe 2>&1 >/dev/null | %FileCheck %s
// CHECK: mg.add: operand shapes differ at runtime in dimension 0

That valid programs are unaffected (eleven runtime shapes):

// Chapter 19: with the pass in the pipeline, programs that satisfy their assertions are unaffected (eleven runtime shapes).
// RUN: %mg-opt %inputs/chain.mlir --convert-mg-to-affine %lower-with-stderr -o %t.llvm.mlir
// RUN: %mlir-translate --mlir-to-llvmir %t.llvm.mlir -o %t.ll
// RUN: %clang -c %t.ll -o %t.o
// RUN: %clang %inputs/chain_harness.c %t.o -o %t.exe
// RUN: %t.exe | %FileCheck %s
// CHECK-NOT: FAIL
// CHECK: ALL SHAPES PASS

The guard, in both placements:

// Chapter 19: an assert nested in a single-block region (an affine.for body) cannot be split into blocks, so the pass LEAVES IT
// (valid IR, still a cf.assert). Run after --lower-affine --convert-scf-to-cf, where no structured control flow remains, the same
// assert IS rewritten. Without the guard the first run produced `'affine.for' op expects region #0 to have 0 or 1 blocks`.
// RUN: %mg-opt %inputs/nested.mlir --mg-lower-assert-to-stderr | %FileCheck %s --check-prefix=LEFT
// RUN: %mg-opt %inputs/nested.mlir --lower-affine --convert-scf-to-cf --mg-lower-assert-to-stderr | %FileCheck %s --check-prefix=REWRITTEN
// LEFT: cf.assert {{.*}}, "value is NaN"
// REWRITTEN-NOT: cf.assert
// REWRITTEN: llvm.call @write

The nested assert run for real, with the message on stderr:

// Chapter 19: an assert inside a loop, run for real. Clean data finishes; a NaN at index 3 aborts with the message on stderr.
// RUN: %mg-opt %inputs/nested.mlir %lower-with-stderr -o %t.llvm.mlir
// RUN: %mlir-translate --mlir-to-llvmir %t.llvm.mlir -o %t.ll
// RUN: %clang -c %t.ll -o %t.o
// RUN: %clang %inputs/nested_harness.c %t.o -lm -o %t.exe
// RUN: %t.exe | %FileCheck %s --check-prefix=CLEAN
// RUN: %not --crash %t.exe nan
// RUN: %not --crash %t.exe nan 2>&1 >/dev/null | %FileCheck %s --check-prefix=NAN
// CLEAN: survived: no NaN found
// NAN: value is NaN

The exact bytes, with --match-full-lines so nothing else may share the line:

// Chapter 19: the message is written byte for byte: an embedded quote, a percent sign, and non-ASCII (UTF-8) text, with exactly one
// trailing newline and nothing else on stderr.
// RUN: %mg-opt %inputs/boom.mlir %lower-with-stderr -o %t.llvm.mlir
// RUN: %mlir-translate --mlir-to-llvmir %t.llvm.mlir -o %t.ll
// RUN: %clang -c %t.ll -o %t.o
// RUN: %clang %inputs/boom_harness.c %t.o -o %t.exe
// RUN: %not --crash %t.exe 2>&1 >/dev/null | %FileCheck %s --match-full-lines --strict-whitespace
// CHECK:bad "quote" 100% done, naïve café

And the collision fix:

// Chapter 19: the pass may run more than once on a module. First run: the top-level assert is rewritten and the one inside the loop is
// left (guard). After flattening, the second run rewrites it. Both messages get DISTINCT global names. Before the fix the counter
// restarted at zero and the second run failed with `redefinition of symbol named 'mg_assert_stderr_msg_0'`.
// RUN: %mg-opt %inputs/twice.mlir --mg-lower-assert-to-stderr --lower-affine --convert-scf-to-cf --mg-lower-assert-to-stderr | %FileCheck %s
// CHECK-DAG: llvm.mlir.global private constant @mg_assert_stderr_msg_0("top-level check\0A")
// CHECK-DAG: llvm.mlir.global private constant @mg_assert_stderr_msg_1("value is NaN\0A")
// CHECK-NOT: cf.assert

The suite, all 52 passing on this chapter's build (run_out_52.txt):

-- Testing: 52 tests, 4 workers --
PASS: IR Forge / Mountain Goat :: execution/mismatch-aborts.mlir (1 of 52)
PASS: IR Forge / Mountain Goat :: execution/dynamic-shapes-run.mlir (2 of 52)
PASS: IR Forge / Mountain Goat :: execution/mismatch-columns-aborts.mlir (3 of 52)
PASS: IR Forge / Mountain Goat :: execution/gpu-host-with-stub-runtime.mlir (4 of 52)
PASS: IR Forge / Mountain Goat :: assert-stderr/message-bytes-exact.mlir (5 of 52)
PASS: IR Forge / Mountain Goat :: dynamic-loops/unroll-runs.mlir (6 of 52)
PASS: IR Forge / Mountain Goat :: execution/static-add-runs.mlir (7 of 52)
PASS: IR Forge / Mountain Goat :: assert-stderr/valid-shapes-still-pass.mlir (8 of 52)
PASS: IR Forge / Mountain Goat :: assert-stderr/message-on-stderr.mlir (9 of 52)
PASS: IR Forge / Mountain Goat :: dynamic-loops/tiling-runs.mlir (10 of 52)
PASS: IR Forge / Mountain Goat :: assert-stderr/nested-assert-runs.mlir (11 of 52)
PASS: IR Forge / Mountain Goat :: execution/strided-view.mlir (12 of 52)
PASS: IR Forge / Mountain Goat :: gpu/pass-order-crash.mlir (13 of 52)
PASS: IR Forge / Mountain Goat :: dynamic-loops/fusion-needs-constant-trip-counts.mlir (14 of 52)
PASS: IR Forge / Mountain Goat :: dynamic-loops/fusion-after-tiling.mlir (15 of 52)
PASS: IR Forge / Mountain Goat :: execution/mixed-static-dynamic.mlir (16 of 52)
PASS: IR Forge / Mountain Goat :: gpu/ptx-index32.mlir (17 of 52)
PASS: IR Forge / Mountain Goat :: gpu/ptx-default.mlir (18 of 52)
PASS: IR Forge / Mountain Goat :: dynamic-loops/runtime-check-survives-transforms.mlir (19 of 52)
PASS: IR Forge / Mountain Goat :: gpu/ptx-bare-pointers.mlir (20 of 52)
PASS: IR Forge / Mountain Goat :: dynamic-loops/unroll-full-needs-constant-trip-count.mlir (21 of 52)
PASS: IR Forge / Mountain Goat :: gpu/host-runtime-calls.mlir (22 of 52)
PASS: IR Forge / Mountain Goat :: gpu/host-runtime-calls-bare.mlir (23 of 52)
PASS: IR Forge / Mountain Goat :: assert-stderr/guard-nested-assert.mlir (24 of 52)
PASS: IR Forge / Mountain Goat :: dynamic-loops/fusion-fires-when-static.mlir (25 of 52)
PASS: IR Forge / Mountain Goat :: gpu/nvvm-lowering.mlir (26 of 52)
PASS: IR Forge / Mountain Goat :: gpu/cuda-comparison.mlir (27 of 52)
PASS: IR Forge / Mountain Goat :: dynamic-loops/unroll-epilogue.mlir (28 of 52)
PASS: IR Forge / Mountain Goat :: gpu/kernel-outlining.mlir (29 of 52)
PASS: IR Forge / Mountain Goat :: dynamic-loops/fusion-declines.mlir (30 of 52)
PASS: IR Forge / Mountain Goat :: dynamic-loops/tiling-structure.mlir (31 of 52)
PASS: IR Forge / Mountain Goat :: verifier/transpose-bad-shape.mlir (32 of 52)
PASS: IR Forge / Mountain Goat :: loops/fusion.mlir (33 of 52)
PASS: IR Forge / Mountain Goat :: canonicalize/transpose-twice-dynamic-mixed.mlir (34 of 52)
PASS: IR Forge / Mountain Goat :: assert-stderr/static-unchanged.mlir (35 of 52)
PASS: IR Forge / Mountain Goat :: verifier/transpose-rank3.mlir (36 of 52)
PASS: IR Forge / Mountain Goat :: lowering/static-add-affine.mlir (37 of 52)
PASS: IR Forge / Mountain Goat :: loops/tiling.mlir (38 of 52)
PASS: IR Forge / Mountain Goat :: assert-stderr/run-twice-no-collision.mlir (39 of 52)
PASS: IR Forge / Mountain Goat :: lowering/dynamic-transpose-no-check.mlir (40 of 52)
PASS: IR Forge / Mountain Goat :: canonicalize/fold-constant-add.mlir (41 of 52)
PASS: IR Forge / Mountain Goat :: assert-stderr/rewrite-structure.mlir (42 of 52)
PASS: IR Forge / Mountain Goat :: lowering/dynamic-add-affine.mlir (43 of 52)
PASS: IR Forge / Mountain Goat :: verifier/unranked-rejected.mlir (44 of 52)
PASS: IR Forge / Mountain Goat :: lowering/mixed-add-one-check.mlir (45 of 52)
PASS: IR Forge / Mountain Goat :: verifier/add-static-mismatch.mlir (46 of 52)
PASS: IR Forge / Mountain Goat :: loops/unroll-full.mlir (47 of 52)
PASS: IR Forge / Mountain Goat :: canonicalize/transpose-twice-dynamic-exact.mlir (48 of 52)
PASS: IR Forge / Mountain Goat :: canonicalize/transpose-twice.mlir (49 of 52)
PASS: IR Forge / Mountain Goat :: lowering/bufferize-static-add.mlir (50 of 52)
PASS: IR Forge / Mountain Goat :: lowering/bufferize-dynamic-add.mlir (51 of 52)
PASS: IR Forge / Mountain Goat :: verifier/dynamic-accepted.mlir (52 of 52)

Testing Time: 1.43s

Total Discovered Tests: 52
  Passed: 52 (100.00%)

Can the tests fail?

Against Chapter 14's build, which has no such pass, the same 52 tests give (older_build_out.txt):

  Failed:  8 (15.38%)
  Passed: 44 (84.62%)
FAIL: assert-stderr/guard-nested-assert.mlir
FAIL: assert-stderr/message-bytes-exact.mlir
FAIL: assert-stderr/message-on-stderr.mlir
FAIL: assert-stderr/nested-assert-runs.mlir
FAIL: assert-stderr/rewrite-structure.mlir
FAIL: assert-stderr/run-twice-no-collision.mlir
FAIL: assert-stderr/static-unchanged.mlir
FAIL: assert-stderr/valid-shapes-still-pass.mlir

All eight new tests fail and the 44 older ones pass. This is weaker evidence than it looks for some of them (static-unchanged fails there only because the option --mg-lower-assert-to-stderr does not exist), so a second, stronger check follows.

Seven deliberate bugs in the pass's own source, each applied to a copy of this chapter's source tree, rebuilt, and tested. The script:

#!/bin/sh
# Four deliberate bugs in the new pass, each applied to a COPY of this chapter's source tree, rebuilt, and tested against the assert-stderr tests.
# Needs ./build.sh to have been run (it creates tree/). Each mutation rebuilds mg-opt, so this takes a few minutes. Output: stderr_mutation_out.txt
# READ THIS FIRST: this script rebuilds the compiler with one deliberate bug at a time and re-runs the tests.
#   FAIL lines (and non-zero exit statuses) in this script's output are the EXPECTED result: each bug SHOULD make at least one test fail.
#   The unmodified, current build is the baseline and must show no failures. A mutation or old build that makes NO test fail would be the problem.
echo "NOTE: this script deliberately runs broken or older code. FAIL lines below are EXPECTED: they show the tests can detect the problem. The baseline (unmodified current build) must show none."
HERE=$(cd "$(dirname "$0")" && pwd); SRC=$HERE/tree; SUITE=$HERE/../../part15/code/test/assert-stderr
mutate() {  # name, description, python edit (receives the path to AssertToStderr.cpp). ONLY=n runs just mutation n.
  [ -z "$ONLY" ] || [ "$ONLY" = "$1" ] || return
  W=$HERE/work/sm-$1; rm -rf "$W"; mkdir -p "$W/build/include/mg"; cp -r "$SRC" "$W/tree"
  python3 -c "$3" "$W/tree/lib/AssertToStderr.cpp" || { echo "MUTATION $1 NOT APPLIED ($2)"; return; }
  (cd "$W/build" && cmake "$W/tree" -DMLIR_DIR=/usr/lib/llvm-18/lib/cmake/mlir -DLLVM_DIR=/usr/lib/llvm-18/lib/cmake/llvm \
     -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_ASSERTIONS=OFF >/dev/null 2>&1 && make -j8 2>&1 | grep -E " error " || true)
  echo "MUTATION $1: $2"
  MG_OPT=$W/build/mg-opt MG_TEST_TMP=$W/out lit "$SUITE" 2>&1 | grep -E "^(PASS|FAIL)" | sed 's/IR Forge \/ Mountain Goat :: /   /;s/ ([0-9]* of [0-9]*)//' | sort
}
mutate 1 "message written to fd 1 (stdout) instead of fd 2 (stderr)" \
  'import sys; p=sys.argv[1]; s=open(p).read(); a="rewriter.getI32IntegerAttr(2)"; assert a in s; open(p,"w").write(s.replace(a,"rewriter.getI32IntegerAttr(1)"))'
mutate 2 "no abort: the failing side falls through to the continuation" \
  'import sys; p=sys.argv[1]; s=open(p).read(); a="    rewriter.create<LLVM::CallOp>(loc, abortFn, ValueRange{});\n    rewriter.create<LLVM::UnreachableOp>(loc);\n"; assert a in s; open(p,"w").write(s.replace(a,"    rewriter.create<cf::BranchOp>(loc, cont);\n"))'
mutate 3 "the func.func-parent guard removed" \
  'import sys; p=sys.argv[1]; s=open(p).read(); a="    if (!isa<func::FuncOp>(op->getParentOp()))\n      return rewriter.notifyMatchFailure(op, \"assert is not directly inside a func.func\");\n"; assert a in s; open(p,"w").write(s.replace(a,""))'
mutate 4 "message length off by two (the last character and the newline are cut)" \
  'import sys; p=sys.argv[1]; s=open(p).read(); a="rewriter.getI64IntegerAttr(text.size())"; assert a in s; open(p,"w").write(s.replace(a,"rewriter.getI64IntegerAttr(text.size() - 2)"))'
mutate 5 "branch targets swapped: a TRUE condition goes to the failing side, so VALID programs abort" \
  'import sys; p=sys.argv[1]; s=open(p).read(); a="op.getArg(), cont, ValueRange{}, fail, ValueRange{}"; assert a in s; open(p,"w").write(s.replace(a,"op.getArg(), fail, ValueRange{}, cont, ValueRange{}"))'
mutate 6 "the pass declares @abort even when there is no assert to rewrite (it alters programs it should leave alone)" \
  'import sys; p=sys.argv[1]; s=open(p).read(); a="    RewritePatternSet patterns(&getContext());\n"; assert a in s; open(p,"w").write(s.replace(a, "    getOrDeclare(getOperation(), \"abort\", LLVM::LLVMVoidType::get(&getContext()), {});\n"+a))'
mutate 7 "global names always start at 0 again (the uniqueness search is removed): a second run collides" \
  'import sys; p=sys.argv[1]; s=open(p).read(); a="      if (!module.lookupSymbol(name))\n        break;\n"; assert a in s; open(p,"w").write(s.replace(a,"      break;\n"))'

and its complete, unedited output:

MUTATION 1: message written to fd 1 (stdout) instead of fd 2 (stderr)
FAIL:    assert-stderr/message-bytes-exact.mlir
FAIL:    assert-stderr/message-on-stderr.mlir
FAIL:    assert-stderr/nested-assert-runs.mlir
PASS:    assert-stderr/guard-nested-assert.mlir
PASS:    assert-stderr/rewrite-structure.mlir
PASS:    assert-stderr/run-twice-no-collision.mlir
PASS:    assert-stderr/static-unchanged.mlir
PASS:    assert-stderr/valid-shapes-still-pass.mlir
MUTATION 2: no abort: the failing side falls through to the continuation
FAIL:    assert-stderr/message-bytes-exact.mlir
FAIL:    assert-stderr/message-on-stderr.mlir
FAIL:    assert-stderr/nested-assert-runs.mlir
FAIL:    assert-stderr/rewrite-structure.mlir
PASS:    assert-stderr/guard-nested-assert.mlir
PASS:    assert-stderr/run-twice-no-collision.mlir
PASS:    assert-stderr/static-unchanged.mlir
PASS:    assert-stderr/valid-shapes-still-pass.mlir
MUTATION 3: the func.func-parent guard removed
FAIL:    assert-stderr/guard-nested-assert.mlir
FAIL:    assert-stderr/run-twice-no-collision.mlir
PASS:    assert-stderr/message-bytes-exact.mlir
PASS:    assert-stderr/message-on-stderr.mlir
PASS:    assert-stderr/nested-assert-runs.mlir
PASS:    assert-stderr/rewrite-structure.mlir
PASS:    assert-stderr/static-unchanged.mlir
PASS:    assert-stderr/valid-shapes-still-pass.mlir
MUTATION 4: message length off by two (the last character and the newline are cut)
FAIL:    assert-stderr/message-bytes-exact.mlir
FAIL:    assert-stderr/message-on-stderr.mlir
FAIL:    assert-stderr/nested-assert-runs.mlir
PASS:    assert-stderr/guard-nested-assert.mlir
PASS:    assert-stderr/rewrite-structure.mlir
PASS:    assert-stderr/run-twice-no-collision.mlir
PASS:    assert-stderr/static-unchanged.mlir
PASS:    assert-stderr/valid-shapes-still-pass.mlir
MUTATION 5: branch targets swapped: a TRUE condition goes to the failing side, so VALID programs abort
FAIL:    assert-stderr/message-bytes-exact.mlir
FAIL:    assert-stderr/message-on-stderr.mlir
FAIL:    assert-stderr/nested-assert-runs.mlir
FAIL:    assert-stderr/valid-shapes-still-pass.mlir
PASS:    assert-stderr/guard-nested-assert.mlir
PASS:    assert-stderr/rewrite-structure.mlir
PASS:    assert-stderr/run-twice-no-collision.mlir
PASS:    assert-stderr/static-unchanged.mlir
MUTATION 6: the pass declares @abort even when there is no assert to rewrite (it alters programs it should leave alone)
FAIL:    assert-stderr/static-unchanged.mlir
PASS:    assert-stderr/guard-nested-assert.mlir
PASS:    assert-stderr/message-bytes-exact.mlir
PASS:    assert-stderr/message-on-stderr.mlir
PASS:    assert-stderr/nested-assert-runs.mlir
PASS:    assert-stderr/rewrite-structure.mlir
PASS:    assert-stderr/run-twice-no-collision.mlir
PASS:    assert-stderr/valid-shapes-still-pass.mlir
MUTATION 7: global names always start at 0 again (the uniqueness search is removed): a second run collides
FAIL:    assert-stderr/message-on-stderr.mlir
FAIL:    assert-stderr/rewrite-structure.mlir
FAIL:    assert-stderr/run-twice-no-collision.mlir
FAIL:    assert-stderr/valid-shapes-still-pass.mlir
PASS:    assert-stderr/guard-nested-assert.mlir
PASS:    assert-stderr/message-bytes-exact.mlir
PASS:    assert-stderr/nested-assert-runs.mlir
PASS:    assert-stderr/static-unchanged.mlir

Reading it: writing to descriptor 1 instead of 2 (mutation 1) fails exactly the tests that discard stdout; dropping the abort (2) fails the tests that require a crash and the structure test; removing the guard (3) fails the guard test and the run-twice test (whose first pass run also meets the nested assert); truncating the length (4) fails the exact-bytes test and the two that match the message; swapping the branch targets so valid programs abort (5) fails valid-shapes-still-pass; declaring abort even when there is nothing to rewrite (6) fails static-unchanged; and removing the unique-name search (7) fails four tests, not just the collision test: with the search gone, even a single run over a program with two asserts (the shape-mismatch programs) gives both globals the same name. Mutations 5 and 6 were added after the first four, because two tests (valid-shapes-still-pass and static-unchanged) had not failed under any mutation, which meant they had not yet been shown able to fail. Mutation 7 was added after the collision bug was found. Every one of the eight new tests has now failed under at least one mutation.

Limits, and what this does not establish

  • POSIX only. The pass emits calls to write and abort, both standard C-library functions on Linux (tested here) and other POSIX systems. Windows was not tried.
  • Descriptor 2 is assumed open and usable. If it is closed, write fails; the return value is ignored and the program aborts anyway. Short writes and EINTR are not handled (a message of a few dozen bytes is written in one call in practice, but that is not guaranteed).
  • The message is a fixed string. cf.assert carries a static message, so the output cannot include the actual mismatching sizes. "operand shapes differ at runtime in dimension 0" says which dimension, not what the two sizes were.
  • Only asserts directly in a function body are rewritten. Nested ones are left to MLIR's default lowering (stdout, and lost when piped) unless the pass is placed after --convert-scf-to-cf.
  • Ordering matters and fails silently. Placed after the standard lowering, the pass does nothing and says nothing (shown above).
  • The earlier chapters keep the old lowering. Chapters 4 through 18's scripts and tests still use MLIR's default cf.assert lowering; only the eight new tests use this pass. The older tests that check the message do so with stdbuf -oL because of the buffering this chapter works around.
  • Still SIGABRT. The response to a mismatch is process death with exit status 134, not a recoverable error.
  • No performance claims. Nothing was timed; the pass adds no work on the success path beyond the branch the default lowering already had.

Reproducing this chapter

cd docs/part19/code
./build.sh               # earlier chapters' files + this chapter's three -> ./build/mg-opt
./demo.sh                # the side-by-side comparison -> demo_out.txt
./show.sh                # the IR and byte evidence under show/
cd ../part15/code
./run_lit.sh             # the 52-test suite (defaults to this chapter's build)
cd ../../part19/code
./stderr_mutation.sh     # the seven mutations (rebuilds per mutation; several minutes)

The bugs/ files come from the mutation builds (work/sm-3 and work/sm-7) after stderr_mutation.sh has run; reproduce_bugs.sh regenerates them. Every file and output above is embedded from the repository when the site is built. As in Chapters 10 through 18, no documentation was consulted: each claim comes from running the commands or reading the code.

Chapter summary

This chapter replaced MLIR's cf.assert lowering with a small pass, --mg-lower-assert-to-stderr, that turns each assert into a conditional branch whose failing side writes the message to file descriptor 2 and aborts. Chapter 14's lowering printed to buffered stdout and then aborted, so the explanation was lost whenever stdout was not a terminal; the new pass delivers it under every capture mode tried, because write has no buffer to flush and stderr is the right stream. Hardening it found two real bugs (an assert nested in a single-block loop body produced invalid IR, and running the pass twice reused global names), both now fixed and pinned. Eight new lit tests (suite: 52) and seven mutations, all caught, with two mutations added specifically because two tests had not yet been shown able to fail.

Deliberately out of scope, stated explicitly: POSIX only, descriptor 2 assumed open, short writes not handled, a fixed message string, nested asserts handled only when the pass runs after --convert-scf-to-cf, silent no-op if misordered, and the earlier chapters left on the default lowering.

Self-check questions

1. Why does puts followed by abort() lose the message when stdout is a pipe, while write(2, ...) followed by abort() does not?

Worked answer: puts writes into the C library's buffered stdout stream. When stdout is a terminal the library flushes at each newline, but when it is a pipe or a file the text stays in memory until the buffer fills or the program exits normally. abort() ends the process immediately without flushing stdio buffers, so the text is discarded. write is a thin wrapper over the operating system's write call with no library buffer: when it returns the bytes are already in the kernel, so a later abort() cannot lose them. Writing to descriptor 2 also puts the message on the stream conventionally used for errors.

2. The pass only rewrites an assert whose parent operation is a func.func. What goes wrong without that guard, and how can the pass still cover a nested assert?

Worked answer: rewriting splits the assert's block in two and adds a failing block, which needs a region that may hold several blocks. An affine.for body is a single-block region, so splitting it produced invalid IR ('affine.for' op expects region #0 to have 0 or 1 blocks). The guard leaves such asserts to MLIR's default lowering. Running the pass after --lower-affine --convert-scf-to-cf, when no structured loops remain and every assert sits in a function body of blocks, makes the nested assert eligible; the nested program was then run for real and a NaN aborted with its message on stderr.

3. Why must the pass run before --convert-func-to-llvm, and how would you notice if it did not?

Worked answer: --convert-func-to-llvm already lowers cf.assert (to puts plus abort), so by the time a later pass runs, no cf.assert is left to rewrite and the pass silently does nothing. You would notice by looking at the IR or the behavior: with the standard lowering first, the output contains two puts calls and zero write calls, and the message is lost again when stdout is a pipe. Nothing warns about this, which is why the chapter shows it and why every test uses the one correct ordering.

4. Running the pass twice on one module used to fail with a redefinition error. What caused it, and what was the fix?

Worked answer: the message globals were named from a counter that restarted at zero on each run. A first run rewrote a top-level assert (global _0) and left a nested one; after flattening, a second run rewrote the nested one and reused _0, so the module had two globals with the same symbol name. The fix searches the module's symbols for the first unused mg_assert_stderr_msg_<k> instead of counting, so each message gets a distinct name regardless of how many times the pass runs.

5. Two of the new tests did not fail under the first four mutations. Why did the chapter add more mutations, and which ones?

Worked answer: a test that has never failed has not been shown to test anything. valid-shapes-still-pass guards against the pass breaking programs whose assertions hold, and static-unchanged guards against the pass altering programs it should leave alone; the first four mutations (wrong descriptor, no abort, no guard, truncated length) broke neither property. Mutation 5 swaps the branch targets so a true condition goes to the failing side, which makes valid programs abort and fails valid-shapes-still-pass; mutation 6 declares abort even when nothing needs rewriting, which changes a program with no asserts and fails static-unchanged. Mutation 7, removing the unique-name search, was added once the collision bug was found.