Skip to content

Carry a fn-pointer's specification across a basic block boundary - #230

Draft
coord-e wants to merge 1 commit into
mainfrom
claude/issue-201-fix-q4bn5v
Draft

Carry a fn-pointer's specification across a basic block boundary#230
coord-e wants to merge 1 commit into
mainfrom
claude/issue-201-fix-q4bn5v

Conversation

@coord-e

@coord-e coord-e commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Fixes #201.

Root cause

A function type states the callee's specification in the type itself, not in a refinement. Type::Function also lowers to chc::Sort::Null, so a fn-pointer local carries no logical content at all — everything it says lives in the type.

At each basic-block entry, live locals are re-typed from their declared MIR type by TypeBuilder::build, whose FnPtr case builds a fully unrefined function type. For a block that inherits its precondition, install_inherited_bb_ty then overwrites only the last parameter's refinement with the predecessor's env state. A refinement cannot express a function's specification, and the parameter is singleton-sorted so the capture loop skips it outright — so the callee's spec is dropped at the block boundary and never restored.

type_call reads the pointer through operand_type(func).ty, gets the unrefined (..) -> .., and relate_fn_sub_type relates the call against true, leaving the result unconstrained.

Before, for let f: fn(i64) -> i64 = add1; let a = f(0); let b = f(a);:

register_basic_block_def bb=bb1 rty=(_0: (), _1: (int) → int, _2: int) → () has_precondition=false

fn_sub_type got=({ int | p0 ν }) → { int | p1 ν $0 }   expected=({ int | ν = 0 }) → { int | p6 ν }     ; bb0
fn_sub_type got=(int) → int                            expected=({ int | ν = _2 }) → { int | p7 ν _2 }  ; bb1

_1 re-typed from its MIR type alone, so the second call sees no specification.

Trigger

The trigger is the block, not the number of calls: a call is a terminator, so the first call in a body sits in the reify cast's own block and is typed precisely, while every later one does not. A single call behind a branch reproduces it just as well.

It is not limited to ReifyFnPointer locals. A fn-pointer parameter hits the same path once it is called from a later block — tests/ui/pass/fn_ptr.rs passes only because its one f(&mut x) happens to share a block with the parameter binding, and adding a second call reproduces the rejection.

Change

install_inherited_bb_ty already materializes a goto target's type from the predecessor's env, so it now hands the function types over directly alongside the precondition it was already capturing:

  • BasicBlockType::set_param_function_ty replaces one parameter's function type.
  • Analyzer::register_basic_block_param_function_tys installs them on a registered block.
  • Analyzer::inherited_function_tys reads them from the env at the goto, for the parameters that stand for locals. An OuterFnParam copy of a function-typed argument is never called, so it is left alone.

The change is inert for a block with no function-typed local live across the boundary: inherited_function_tys returns an empty vector and the installation loop does nothing.

Blocks that need their own precondition are untouched — those get a template function type from for_function_template and go through relate_fn_param_sub_types as before. Join points and loop headers verify through that path both before and after.

Testing

  • tests/ui/{pass,fail}/fn_ptr_call_twice.rs, fn_ptr_call_in_branch.rs, and fn_ptr_param_call_twice.rs added. Each fail one is the pass one with the property broken as narrowly as possible; all three are rejected without this change and pass with it.
  • All three reproducers from the issue verify as safe, and every row of the issue's behavior matrix now matches its Expected column, including the two that already passed.
  • Soundness spot-checks still report Unsat: b == 3 and b == 1 on the two-call program, x == 3 on the &mut one, the branch variant, the two-target variant, and a fn pointer chosen by a branch (if c { add1 } else { add2 }). Values after a fn-pointer call are constrained, not vacuous.
  • cargo test with the PCSat tests filtered out: 282 passed, 0 failed, plus 2 doc-tests.
  • The 32 PCSat tests (tests/thrust-pcsat-wrapper) were then run directly, sequentially, on this branch and on main at cd33fbf. The verdicts are identical: 31 verify as expected on both, and fail/option_map.rs is undecided on both, hitting a 150s solver timeout. Each of the other 31 finishes in 0–5s.
  • cargo fmt --all -- --check and cargo clippy -- -D warnings are clean.

Two things found while testing, neither addressed here:

A call is a MIR terminator, so a fn-pointer value called more than once, or
called behind a branch, is read in a block other than the one that produced it.
Each block re-types its live locals from their MIR types, which builds an
unrefined `(..) -> ..` for a `fn(..)` local, and the call then related against
that instead of the callee's inferred specification. Both the precondition and
the postcondition were dropped, leaving the call's result unconstrained, so
programs as simple as

    let f: fn(i64) -> i64 = add1;
    let a = f(0);
    let b = f(a);
    assert!(b == 2);

were rejected as `Unsat`.

A block that inherits its precondition takes it from the predecessor's outgoing
env state, and a function type is of a singleton sort, so nothing of it survives
that capture: the specification is spelled out in the type rather than in a
refinement. Hand the types over as they are alongside the precondition, in the
same place that already materializes an inheriting target's type.

The specification of a fn-pointer parameter reaches a later block this way too,
which the existing `fn_ptr` pair could not tell apart: its single call sits in
the block that binds the parameter. Each of the three added pairs breaks in the
absence of the fix and none is vacuous, since the failing side asserts the value
the call actually produces.

Fixes #201

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bFB7y5QM3ebxvtusQYZBo
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants