Files
2026-02-16 12:50:51 +01:00

13 KiB

Assertions and Validation Guide

Purpose

This document defines how to design assertions/validation checks in the Python bindings so that:

  1. The Python process never crashes.
  2. User mistakes produce clear Python exceptions.
  3. Invalid operations do not leave LLVM IR in a half-modified state.

This is a practical guide for implementing and testing safety checks in src/llvm-nanobind.cpp.

Non-Negotiable Rules

  1. No hard crash for recoverable misuse.
  2. Validate everything that can be validated before mutating LLVM state.
  3. If validation fails, the IR must remain byte-for-byte unchanged.
  4. Every non-trivial guard needs a regression test.

Error Model

Use exceptions to classify failure type:

  • LLVMMemoryError: lifetime/use-after-dispose/null wrapper state.
  • LLVMAssertionError: API misuse or IR rule violation by caller.
  • LLVMError: runtime operation failures from LLVM APIs (I/O/parsing/etc.).

Validation errors should generally be LLVMAssertionError with explicit, actionable messages.

Validation Layers

1) Wrapper Lifetime and Null State

Always check wrapper validity first:

  • Wrapper pointer exists (m_ref != nullptr)
  • Context token is valid (m_context_token)

Do this in check_valid() and call it at method entry.

2) Operand/Argument Shape

Validate:

  • Object categories (instruction vs type vs block)
  • Index bounds
  • Required parent relationships
  • Non-null required operands

Example: get_operand(index) must check bounds and throw, never let LLVM perform unchecked access.

Pattern for indexed APIs:

  • Validate value kind/opcode first.
  • Validate index bounds with explicit num_* in the message.
  • Validate returned pointer/reference is non-null when the C API can return null.
  • Include API name in the error message prefix (e.g. get_incoming_value: ...) so failures are searchable.

3) Cross-Context Ownership

Reject cross-context operations unless explicitly supported.

Example: instruction move APIs must reject moving between different context tokens:

  • Cannot move instructions across different contexts

4) IR Placement and Structural Invariants

Enforce LLVM structural rules up front:

  • PHI nodes must stay in the PHI prefix.
  • Non-PHI instructions cannot be inserted before PHIs.
  • LandingPad must be the first non-PHI in its block.
  • Terminator placement constraints:
    • Terminator can only be inserted at end.
    • Non-terminator cannot be inserted after existing terminator.

5) Mutation Safety (No Half-Modified State)

The rule is:

validate -> compute insertion state -> validate insertion -> mutate

Do not unlink/remove/splice until all checks pass.

If you must derive insertion state that depends on current placement, derive it without mutating first and handle adjacency/self-move cases explicitly.

Transaction Pattern for Safe Mutations

For operations that may alter IR:

  1. Validate input wrappers and types.
  2. Validate context compatibility.
  3. Compute destination and insertion point.
  4. Validate insertion legality.
  5. Only then mutate IR.
  6. If any step fails before mutation, throw and return.

This prevents "reject + partially changed IR" bugs.

Guard Implementation Patterns (Current Repo)

Opcode-gated Accessors

Use a single templated opcode gate for instruction-only APIs:

  • require_instruction_opcodes<...>(api_name)

Implementation guidance:

  • For single-opcode APIs, auto-generate human text from opcode name (a/an + opcode), do not hand-maintain per-call strings.
  • For multi-opcode APIs, format expected set once and reuse.
  • Keep message format stable:
    • {api_name} requires {expected} instruction (got {actual})

Branch Successor vs Operand Ordering

LLVM BranchInst has a non-obvious layout for conditional branches:

  • successor order (get_successor / successors) is [true, false]
  • raw operand order (get_operand) is [cond, false, true]

Implications for bindings/tests:

  • Never assume successor index i maps to operand index i + 1.
  • For conditional br:
    • successor 0 maps to operand 2
    • successor 1 maps to operand 1
  • Document this in user-facing API docs and examples to avoid CFG edge bugs.

Exception-First Accessors (Avoid Optional Footguns)

When absence is an edge-case and a cheap predicate exists, prefer an exception-first accessor over std::optional:

  • Pattern:
    • has_* predicate for probing
    • accessor throws with actionable guidance when missing
  • Example checks:
    • is_declaration before entry_block
    • basic_block_count > 0 before first_basic_block / last_basic_block
    • has_personality_fn / has_prefix_data / has_prologue_data before corresponding getters

This keeps happy-path types simple and removes repetitive is not None checks.

std::optional / None is acceptable when absence is the expected semantics:

  • Boundary/navigation APIs:
    • next_* / prev_* style traversal where "no neighbor" is normal.
  • Lookup/query APIs:
    • name-based or key-based lookup where "not found" is expected.
  • IR features that are truly optional by design:
    • e.g. unwind destination, GC name, optional metadata links.
  • Construction-time transient states:
    • e.g. empty basic blocks while building IR.

Prefer exceptions instead when missing state usually indicates a caller mistake and a cheap guard predicate exists.

When a semantic-optional value is still awkward in common mutation flows, prefer adding a convenience helper over changing semantics. Example:

  • BasicBlock.first_non_phi remains optional (empty/PHI-only blocks are valid)
  • BasicBlock.create_builder(first_non_phi=True) provides the ergonomic insertion path (before first non-PHI, otherwise at end)

Helper/Free Function Guards

Do not assume member guards cover helper-bound APIs. If a method is exposed via a helper/free function binding, that helper must independently enforce:

  • kind/opcode checks
  • index bounds
  • null-return checks

Example fixed during this audit: PHI incoming block helper.

Global Kind Guards

Guard global helpers by exact category before calling LLVM-C:

  • global variable only: initializer, thread-local/external-init flags, delete
  • global value: linkage/visibility/dll storage
  • global object: comdat, section

Audit Findings (2026-02-16)

  1. LLVMGetIndices on a GEP instruction can corrupt/crash in practice.
    • Safe contract now: indices accepts extractvalue/insertvalue instructions and constant-expression GEP/extractvalue/insertvalue.
    • GEP instruction users should use num_indices and other GEP accessors, not indices.
  2. Several opcode-specific accessors previously relied on LLVM internals (potential assertions/crashes):
    • predicates/flags (icmp_predicate, fcmp_predicate, nsw/nuw/exact/nneg, disjoint, icmp_same_sign)
    • atomic/cmpxchg/tail-call accessors
    • callsite attribute APIs
  3. Callsite attribute index now validates full range:
    • lower bound: idx >= -1
    • upper bound: idx <= num_arg_operands This prevents both unsigned wraparound and out-of-range callsite attribute queries.
  4. Non-Value Type APIs had missing structural/index guards:
    • struct_element_count now requires struct type.
    • set_body now requires identified opaque struct type.
    • get_struct_element_type now validates element index bounds.
    • target extension type param/int-param accessors now validate index bounds.
  5. Builder placement APIs now enforce instruction preconditions before calling LLVM-C:
    • create_builder(inst) and value.create_builder() require instruction values attached to a basic block.
    • position_before, position_at, insert_into_builder_with_name, add_metadata_to_inst now reject non-instruction values explicitly.
  6. Function/block ownership checks were added:
    • block_address requires block ownership by the given function.
    • append_existing_basic_block requires an unattached block.
  7. Function attribute APIs now validate index bounds:
    • valid index set is -1 (function), 0 (return), 1..param_count.
  8. OperandBundle.get_arg_at_index now validates index bounds before calling LLVM-C and reports num_args.
  9. Binary.sections/Binary.symbols are now gated by binary type. Calling object iterators on non-object binaries (e.g., IR/bitcode) now raises LLVMAssertionError instead of hard-crashing.

Preserve Semantics

When the API supports preserve: bool for movement:

  • preserve=False: normal insertion positioning.
  • preserve=True: use debug-record-preserving insertion points where available (LLVMPositionBuilderBeforeInstrAndDbgRecords in C API).

Preserve mode should not weaken structural validation.

High-Risk Footguns to Guard

Instruction Movement

Risks:

  • Cross-context moves
  • Invalid placement (PHI/landingpad/terminator)
  • Mutation before validation causing corrupt/partial state

Required guards:

  • Category checks (LLVMIsAInstruction)
  • Context token check
  • Destination parent/module checks
  • Placement validation
  • Validate before unlink

Iterator Semantics Over LLVM Iterators

Risks:

  • Returning advanced/end iterator object to Python property access
  • Undefined behavior or crashes in underlying C API

Required behavior:

  • Python __next__ returns current item.
  • Advance between calls.
  • End-state must raise StopIteration before property access.

Binary/Object Parsing from Stdin

Risks:

  • Corrupted input handling can trigger LLVM aborts
  • Missing upfront format checks/clear errors

Required behavior:

  • Read bytes paths only (sys.stdin.buffer.read() in Python tools)
  • Wrap creation/parsing in exception handling
  • Avoid exposing invalid iterator states

Lifetime Escapes

Risks:

  • Accessing values/types/builders after context/module disposal

Required behavior:

  • check_valid() on every public operation
  • Strong tests that escaped objects raise Python exceptions, not crashes

Testing Strategy for Assertions

1) Positive + Negative Pairing

For each guarded API:

  • Positive test: valid operation works.
  • Negative test: invalid operation raises expected exception.

2) No-Mutation-on-Failure Assertions

For every negative move/mutate test:

  1. Capture pre-state:
    • instruction order
    • parent block links
    • optionally full IR string (mod.to_string())
  2. Perform invalid op and assert exception.
  3. Assert post-state equals pre-state.
  4. mod.verify() must still pass.

3) Crash Repro Regressions

If a crash is found:

  1. Add minimal pure-Python repro in tests/regressions/.
  2. Add assertions for expected exception.
  3. Keep test deterministic and fast.

4) Coverage Expectations

When adding non-trivial guards:

  • Add or extend regression tests so both success and failure paths execute.
  • Run:
    • uv run run_tests.py
    • uv run run_tests.py --regressions
    • uv run run_llvm_c_tests.py --use-python when affected.

Current Safety Examples in This Repo

Instruction Move Safety

Implemented validation includes:

  • Context checks
  • PHI/landingpad/terminator placement constraints
  • Validation before unlinking/removal
  • Optional preserve mode

See:

  • src/llvm-nanobind.cpp (instruction movement and insertion validation)
  • tests/regressions/test_instruction_move_and_aliases.py

Iterator Safety

Binary/section/symbol/relocation iterators now follow safe Python iterator protocol and avoid exposing advanced/end state as current items.

See:

  • src/llvm-nanobind.cpp iterator __next__ implementations
  • tests/regressions/test_binary_iterators.py

Raw Bytes Constant Safety

Byte-preserving constant APIs avoid UTF-8 expansion footguns for binary data paths.

See:

  • tests/regressions/test_const_bytes.py

Value Accessor Guard Coverage

Value kind/opcode guard coverage and crash repros are tracked in:

  • tests/regressions/test_value_kind_guards.py
  • tests/regressions/test_memory_delete_instruction.py

Non-Value Guard Coverage

Comprehensive non-Value guard matrices are tracked in:

  • tests/regressions/test_type_guard_matrix.py
  • tests/regressions/test_non_value_guard_matrix.py
  • tests/regressions/test_full_tree_guard_matrix.py

Assertion Message Quality

Good messages:

  • Name the violated rule.
  • Name the operand/position involved.
  • Avoid generic "invalid argument".

Examples:

  • PHI nodes must be inserted in the PHI prefix of the basic block
  • Cannot insert non-terminator instruction after block terminator
  • Cannot move instructions across different contexts

Review Checklist (Before Merging)

  • Did we validate all preconditions before mutation?
  • Can the failure path leave partial IR changes?
  • Do negative tests assert IR unchanged?
  • Do tests cover both success and failure?
  • Are exception types and messages specific?
  • Did we run regression + golden master suites?
  • For tool-path changes, did we run llvm-c lit tests in Python mode?
  • devdocs/DEBUGGING.md
  • devdocs/memory-model.md
  • devdocs/lit-tests.md
  • devdocs/api-reference.md