Detect stub_verified/Arbitrary recursion at compile time - #4571
Detect stub_verified/Arbitrary recursion at compile time#4571feliperodri wants to merge 1 commit into
stub_verified/Arbitrary recursion at compile time#4571Conversation
70828a2 to
7812142
Compare
7812142 to
afdd4ab
Compare
|
I attempted to implement a runtime fix (nesting depth counter) but it conflicts with CBMC's DFCC assigns checking: writes to global mutable state inside contract-checked scopes trigger assigns violations. CBMC provides no mechanism to exempt infrastructure writes from DFCC tracking. So back to the drawing board... |
afdd4ab to
18b4f8b
Compare
There was a problem hiding this comment.
Pull request overview
This PR addresses an infinite-recursion/hang scenario caused by #[kani::stub_verified] contract replacements re-entering themselves through kani::any::<Ret>() when Ret’s kani::Arbitrary implementation calls the stubbed function. The implementation in this diff prevents the hang by detecting the cycle during compilation and emitting a targeted error with a call-path trace, and it documents the limitation/workarounds.
Changes:
- Add a
stub_verified/Arbitrarycycle check inFunctionWithContractPass(compiler MIR transform) and emit a compile-time diagnostic with a call trace. - Add tests covering (a) a safe derived-
Arbitrarycase that should verify, and (b) a cycle case that should error with an expected message. - Document the limitation and workarounds in both the RFC and the user reference docs.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/kani/FunctionContracts/stub_verified_safe_arbitrary.rs | New positive test ensuring stub_verified works when derived Arbitrary does not call the stubbed function. |
| tests/expected/function-contract/stub_verified_arbitrary_cycle.rs | New negative test setting up the stub_verified/Arbitrary recursion cycle. |
| tests/expected/function-contract/stub_verified_arbitrary_cycle.expected | Expected diagnostic output for the new cycle detection error. |
| rfc/src/rfcs/0002-function-stubbing.md | RFC documentation of the stub_verified/Arbitrary interaction and workarounds. |
| kani-compiler/src/kani_middle/transform/contracts.rs | Compiler-side cycle detection logic and diagnostic emission for verified stubs. |
| docs/src/reference/experimental/contracts.md | User docs note about the stub_verified/Arbitrary recursion limitation and mitigation. |
Suppressed comments (2)
kani-compiler/src/kani_middle/transform/contracts.rs:533
- The
traceformatting in this diagnostic does not match the expected output intests/expected/function-contract/stub_verified_arbitrary_cycle.expected: it currently (1) includes thekani::any::<Ret>frame, (2) does not prefix the first line with->, and (3) adds extra indentation before the trace. This will make the UI test brittle / fail.
Consider building the trace as one -> ... entry per line and dropping the initial kani::any::<Ret> frame so the first line is the Arbitrary::any call (which is where the recursion actually starts).
// Use the resolved instance name for the trace tail so it matches the
// crate-qualified names that `Instance::name` produces for the path.
let trace = path.join("\n -> ") + "\n -> " + &instance.name();
tcx.dcx()
.struct_span_err(
kani-compiler/src/kani_middle/transform/contracts.rs:316
- After switching
arbitrary_cycle_checkedto be keyed by monomorphizedInstance, this insert should use theinstanceparameter rather than*def; otherwise different instantiations of the same generic function still get deduped and may skip the cycle check.
if mode == ContractMode::Replace && self.arbitrary_cycle_checked.insert(*def) {
self.check_arbitrary_cycle(tcx, *def, args);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
18b4f8b to
f89ad33
Compare
stub_verified infinite recursion when Arbitrary calls the stubbed functionstub_verified/Arbitrary recursion at compile time
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (1)
kani-compiler/src/kani_middle/transform/contracts.rs:676
find_call_pathtreats any call to the sameFnDefas reaching the stubbed target (callee_def.def_id() == target.def_id()). For genericstub_verifiedtargets, this can produce false positives: the return type’sArbitraryimpl might call a different monomorphization of the same generic function (samedef_id, differentcallee_args), which does not necessarily re-enter the specific replacement instance being checked. Since this pass already deduplicates checks per monomorphizedInstance, the target comparison should also be instance-precise (def_id + args).
continue;
};
if callee_def.def_id() == target.def_id() {
return Some(vec![from.name()]);
A contract replacement havocs its own return value with `kani::any::<Ret>()`
(`initial_replace_stmts` emits `any_modifies`, which `AnyModifiesPass` rewrites
to `kani::any`). So when the `Arbitrary` implementation for `Ret` reaches the
stubbed function, the replacement re-enters itself through `Arbitrary::any`:
normalize -> replace closure -> kani::any::<Wrapper>
-> <Wrapper as Arbitrary>::any -> Wrapper::new -> normalize -> ...
This recursion has no fixpoint, so CBMC unwinds it until it exhausts memory.
Previously this surfaced as a multi-minute hang ending in an out-of-memory
message that never named the cause.
Note the cycle is self-contained inside the replacement: it reproduces even
when the harness passes a concrete value and never calls `kani::any()` itself,
so a caller-side guard cannot break it.
Detect it instead: for each `Replace`-mode target, resolve `kani::any::<Ret>`
and walk the monomorphized call graph for a path back to the target. If one
exists, emit an error naming the call path and suggesting `#[derive(Arbitrary)]`.
This follows the existing `check_mutual_recursion` precedent.
Both the per-target dedup and the call-graph comparison are instance-precise
rather than keyed on `DefId`. A generic target can be instantiated at several
return types, each with its own potential cycle, and a return type's `Arbitrary`
impl may call a *different* monomorphization of the same generic function
without re-entering the instance under check. Comparing by `DefId` alone would
both skip later cyclic instantiations and reject working proofs that route
through a sibling instantiation.
The walk follows only statically resolvable calls, so a cycle routed through a
function pointer or trait object is not reported. This is deliberate: a missed
detection reproduces prior behavior, whereas a false positive would reject a
working proof.
Changes:
- kani-compiler/src/kani_middle/transform/contracts.rs: `check_arbitrary_cycle`
and the `find_call_path` call-graph walk, run once per replace instance.
- tests/expected/function-contract/stub_verified_arbitrary_cycle.{rs,expected}:
regression test for the new diagnostic.
- tests/expected/function-contract/stub_verified_arbitrary_cycle_generic.{rs,expected}:
regression test covering a generic target where only the second
instantiation is cyclic.
- tests/kani/FunctionContracts/stub_verified_arbitrary_other_instantiation.rs:
regression test ensuring a call to a sibling instantiation is not a false
positive.
- tests/kani/FunctionContracts/stub_verified_safe_arbitrary.rs: derived
`Arbitrary` (no cycle) still verifies with `stub_verified`.
- docs, rfc: document the mechanism and the detection limitation.
Signed-off-by: Felipe R. Monteiro <felisous@amazon.com>
f89ad33 to
a1598cd
Compare
Problem
When a type's
kani::Arbitraryimplementation reaches a function targeted by#[kani::stub_verified], verification never terminates — CBMC unwinds until it exhausts memory, after several minutes, with an out-of-memory message that never names the cause.The cause is the contract replacement's own return-value havoc.
initial_replace_stmtsemitskani::any_modifies::<Ret>(), whichAnyModifiesPassrewrites tokani::any::<Ret>(). So ifRet'sArbitraryimpl reaches the stubbed function, the replacement re-enters itself:The cycle is self-contained inside the replacement. It reproduces even when the harness passes a concrete value and never calls
kani::any()itself — so a caller-side guard cannot break it. This recursion has no fixpoint, so no unwind bound makes it converge.Solution
Detect the cycle at compile time rather than letting verification hang. For each
ContractMode::Replacetarget,FunctionWithContractPassresolveskani::any::<Ret>and walks the monomorphized call graph for a path back to the target. If one exists, it emits an error naming the path:This follows the existing
check_mutual_recursionprecedent for the analogous unsupported-recursion case.Instance-precise, not
DefId-basedBoth the per-target dedup and the call-graph comparison are keyed on the monomorphized
Instance, not theDefId. A genericstub_verifiedtarget can be instantiated at several return types, each with its own potential cycle, and a return type'sArbitraryimpl may call a different monomorphization of the same generic function without re-entering the instance under check. Comparing byDefIdalone would both:Why not the runtime guard
An earlier revision of this PR tracked
kani::any()nesting depth (ARBITRARY_NESTING_DEPTH) and ran the original body when depth > 0. That approach is not viable and has been removed:kani::any(), so the guard fires inside the stub.stub_verifiedwould silently drop its abstraction for calls having nothing to do withArbitrary.Arbitraryimpl." Any contract-replaced call under akani::any()frame loses its stub; whether that's sound depends on the caller, so it is not the blanket win the earlier description claimed.modifiesclause.REENTRYsurvives only because--nondet-static-excludesuppresses havocking (not assigns-tracking) and it is written outside the--enforce-contractframe; a counter insidekani::any()is inside that frame.Limitation
Detection follows only statically resolvable calls, so a cycle routed through a function pointer or trait object is not reported. This is deliberate: a missed detection reproduces prior behavior, whereas a false positive would reject a working proof. Documented in the code and RFC.
This PR makes the failure diagnosable; it does not make the pattern verify. Doing that would require the return havoc to stop routing through user
Arbitrary, which changes the soundness model of everystub_verifiedreturn value and warrants separate design discussion.Testing
tests/expected/function-contract/stub_verified_arbitrary_cycle.{rs,expected}— regression test for the new diagnostic.tests/expected/function-contract/stub_verified_arbitrary_cycle_generic.{rs,expected}— a generic target where only the second instantiation is cyclic; guards againstDefId-keyed dedup skipping it.tests/kani/FunctionContracts/stub_verified_arbitrary_other_instantiation.rs— a call to a sibling instantiation whose chain terminates; guards against aDefId-based false positive. Must verify.tests/kani/FunctionContracts/stub_verified_safe_arbitrary.rs— derivedArbitrary(no cycle) still verifies withstub_verified.Full runs: 592/592
kani,expectedanduisuites green (the only failure,expected/shadow/unsupported_num_objects, is pre-existing and unrelated — it fails identically on a clean tree). Both CI clippy invocations andkani-fmt --checkare clean.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 and MIT licenses.