Carry what a type states across a basic block boundary - #230
Draft
coord-e wants to merge 2 commits into
Draft
Conversation
coord-e
force-pushed
the
claude/issue-201-fix-q4bn5v
branch
from
August 20, 2026 12:10
d974768 to
5961f61
Compare
A function type states the callee's specification in the type itself, and `Type::Function` lowers to a null sort, so a fn-pointer local carries no logical content at all. A block that inherits its predecessor's env state as its precondition therefore cannot receive that specification: the precondition is a formula about the parameters' values, and there is no value to speak about. The capture loop skips the parameter as singleton-sorted, `TypeBuilder::build` has meanwhile rebuilt it from its MIR type alone, and `type_call` relates the call against `true`, leaving the result unconstrained. Because a call ends its block, the first call in a body sits in the reify cast's own block and is typed precisely, while every later one is not; a single call behind a branch is enough on its own. A fn-pointer parameter reaches the same path once it is called from a later block. `install_inherited_bb_ty` already materializes the target's type from the env, so hand the function types over there too. The copy walks into the type rather than matching only its root, so a function type nested inside a tuple or a struct is carried across as well. A position where the two shapes disagree is left alone: missing a specification costs precision, while taking one from an unrelated position would be wrong. Relating the two types at the goto instead, as the other path in `type_goto` does, would achieve nothing here: the target's type is built without predicate variables, so there would be nothing for the subtyping to constrain.
coord-e
force-pushed
the
claude/issue-201-fix-q4bn5v
branch
from
August 20, 2026 12:18
5961f61 to
788fa99
Compare
A block that inherits its precondition takes over what its predecessor's env says about each parameter's *value*, and is otherwise typed from MIR types alone. Everything a type states by itself is dropped at that boundary: the refinements nested in it, and the specification a function type spells out. A fn-pointer value is where this shows: called from a block other than the one that created it, it is related against an unrefined `(..) -> ..`, so the callee's pre- and postcondition are both gone and the result is left unconstrained. A call ends its block, so the first call in a body is typed precisely and every later one is not. Hand the env's types over alongside the precondition. A type in the env is closed -- a refinement nested in one constrains the value at its own position and names nothing from the env -- so it transfers as it stands, and `assert_closed` pins that down. Blocks that need their own precondition keep relating the two types at the goto, which already transferred all of this. Fixes #201. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014bFB7y5QM3ebxvtusQYZBo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #201.
Root cause
A block that inherits its precondition gets two things and no more: the target's parameters, typed from their MIR types alone, and a formula capturing what the predecessor's env says about each parameter's value.
PrecondCaptureis where the second part is assembled, and it only ever reads a value:and
push_env_statefolds inrty.refinement— the outer refinement of{ T | phi }— leavingTalone. So everything a type states by itself is dropped at the boundary and rebuilt unrefined from MIR: the refinements nested inside it, and the specification a function type spells out.A fn-pointer value is where this becomes visible, because a function type has nowhere else to keep its specification.
type_callreads the pointer throughoperand_type(func).ty, gets an unrefined(..) -> .., andrelate_fn_sub_typerelates the call againsttrue, leaving the result unconstrained.Before, for
let f: fn(i64) -> i64 = add1; let a = f(0); let b = f(a);: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
ReifyFnPointerlocals, nor to the root of a type. A fn-pointer parameter hits the same path once it is called from a later block, and so does a fn pointer nested in a tuple.Change
install_inherited_bb_tyalready materializes a goto target's type from the predecessor's env, so it now hands the env's types over there too, alongside the precondition it was already capturing.A type in the env is closed: a refinement nested in one constrains the value at its own position and names nothing from the env. So the type transfers as it stands, with no substitution —
assert_closedstates that invariant where it is relied on. The sort assertion beside it holds the env's view and the MIR-built parameter to the same shape.Blocks that need their own precondition are untouched. Those get template types and relate the two sides at the goto, and that relation — being structural — already carried all of this, nested positions included.
Why not relate the types at the goto here too
Because on this path there is nothing to relate against:
The target's type comes from
TypeBuilder::build_basic_block, which registers no templates (itsFakeRegistrypanics if asked to). With no predicate variables in the expected type, a subtyping relation yieldstrue ⟹ true, and the block body goes on reading its parameters from that same type.Sending these blocks down the predicate-variable path instead was tried and is not a one-line change: it makes
pass/option_unwrap_or_elseandfail/option_unwrap_or_elseabort intype_goto, whereassert_closedmeets a parameter type that is not closed, and it pushespass/closure_receiver_mut_modelpast a 150s solver timeout. Unifying the two paths looks worthwhile but wants that invariant sorted out first.Testing
tests/ui/{pass,fail}/fn_ptr_call_twice.rs,fn_ptr_call_in_branch.rs,fn_ptr_param_call_twice.rs, andfn_ptr_in_tuple_call_twice.rsadded. Eachfailone is thepassone with the property broken as narrowly as possible. The last pins the nested case, which a version of this change that looked only at the root of a type did not fix.safe, and every row of the issue's behavior matrix matches its Expected column.Unsat:b == 3andb == 1on the two-call program,x == 3on the&mutone, the branch variant, the two-target variant, and a fn pointer chosen by a branch (if c { add1 } else { add2 }).cargo testwith the PCSat tests filtered out: 284 passed, 0 failed, plus 3 unit tests and 2 doc-tests.mainat cd33fbf, with identical verdicts: 31 as expected on both, andfail/option_map.rsundecided on both at a 150s solver timeout.assert_closedrelies on was measured before relying on it: instrumented to report any variable occurring in an env type at a goto, then run over all 316 test files. No occurrences.cargo fmt --all -- --checkandcargo clippy -- -D warningsare clean.Two things found while testing, neither addressed here:
fn(..)pointer,&str) stored in an aggregate that also has a non-singleton field is filtered out ofEnv::dependencies, aborting withunbound varorno entry found for key#217: a null-sorted value (fn(..)pointer or&str) stored in an aggregate that also has a non-singleton field aborts in constraint construction. It reproduces identically onmain. That is whyfn_ptr_in_tuple_call_twiceuses a one-element tuple.THRUST_OUTPUT_DIRdumps cannot be diffed to compare two builds. Worth a separate issue if determinism there is wanted.Since the fix is not specific to function pointers, #201's framing is narrower than the defect. Happy to retitle it, or to leave it as the fn-pointer symptom and open a separate issue for the general loss — whichever you prefer.