Carry a fn-pointer's specification across a basic block boundary - #230
Draft
coord-e wants to merge 1 commit into
Draft
Carry a fn-pointer's specification across a basic block boundary#230coord-e wants to merge 1 commit into
coord-e wants to merge 1 commit into
Conversation
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
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 function type states the callee's specification in the type itself, not in a refinement.
Type::Functionalso lowers tochc::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, whoseFnPtrcase builds a fully unrefined function type. For a block that inherits its precondition,install_inherited_bb_tythen 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_callreads the pointer throughoperand_type(func).ty, gets the 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);:_1re-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
ReifyFnPointerlocals. A fn-pointer parameter hits the same path once it is called from a later block —tests/ui/pass/fn_ptr.rspasses only because its onef(&mut x)happens to share a block with the parameter binding, and adding a second call reproduces the rejection.Change
install_inherited_bb_tyalready 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_tyreplaces one parameter's function type.Analyzer::register_basic_block_param_function_tysinstalls them on a registered block.Analyzer::inherited_function_tysreads them from the env at the goto, for the parameters that stand for locals. AnOuterFnParamcopy 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_tysreturns 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_templateand go throughrelate_fn_param_sub_typesas 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, andfn_ptr_param_call_twice.rsadded. Eachfailone is thepassone with the property broken as narrowly as possible; all three are rejected without this change and pass with it.safe, and every row of the issue's behavior matrix now matches its Expected column, including the two that already passed.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 }). Values after a fn-pointer call are constrained, not vacuous.cargo testwith the PCSat tests filtered out: 282 passed, 0 failed, plus 2 doc-tests.tests/thrust-pcsat-wrapper) were then run directly, sequentially, on this branch and onmainat cd33fbf. The verdicts are identical: 31 verify as expected on both, andfail/option_map.rsis undecided on both, hitting a 150s solver timeout. Each of the other 31 finishes in 0–5s.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, and no workaround for it is included in this PR.THRUST_OUTPUT_DIRdumps cannot be diffed to compare two builds. Worth a separate issue if determinism there is wanted.