Skip to content

Commit f9941ce

Browse files
Check each loop iteration from the same facts, and follow only recorded jumps
Review of the loop typestate fixpoint found programs it accepted and main rejected: - An iteration started from the values the previous one left: in `int x = 100; while (x < 10) { b.close(); x = 0; }` the last check had x == 0, so the loop could not exit through its condition, no state was possible after it, and every later check passed. A lambda error in the first iteration was lost the same way. Each iteration is now checked in its own context scope, so what it adds (instances, variables, path conditions) is dropped after it, and all iterations start from the facts before the loop with what it changes havocked. - A labelled block ending the body (`done: { break done; }`) was taken as never completing, hiding the next iteration. A statement that a labelled break leaves now completes normally. - A break to a labelled block around the loop, or through a finally or try-with-resources, recorded its state nowhere or before the cleanup ran. Loops with such jumps havoc their objects' typestates, as main. As a guard, an object with no state where the loop exits is havocked, since Java rejects code after a loop that never exits. Also: the simple-name lookup of functions is indexed in the context, and jump targets are resolved in one helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4c8a059 commit f9941ce

5 files changed

Lines changed: 222 additions & 25 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package testSuite;
2+
3+
import liquidjava.specification.StateRefinement;
4+
import liquidjava.specification.StateSet;
5+
6+
// Typestates through the jumps in a loop body
7+
@StateSet({"open", "marked", "closed"})
8+
public class ErrorLoopStateJumps {
9+
@StateRefinement(to = "open(this)")
10+
ErrorLoopStateJumps() {}
11+
12+
@StateRefinement(from = "open(this)", to = "marked(this)")
13+
@StateRefinement(from = "marked(this)")
14+
void mark() {}
15+
16+
@StateRefinement(from = "marked(this)", to = "open(this)")
17+
void reset() {}
18+
19+
@StateRefinement(from = "!closed(this)", to = "closed(this)")
20+
void close() {}
21+
22+
@StateRefinement(from = "open(this)")
23+
void needOpen() {}
24+
25+
// a break out of a labelled block at the end of the body still goes on to the next iteration
26+
static void labelledBlockEndsBody(int n) {
27+
ErrorLoopStateJumps b = new ErrorLoopStateJumps();
28+
while (n > 0) {
29+
b.close(); // Expect: State Refinement Error
30+
n--;
31+
done: {
32+
break done;
33+
}
34+
}
35+
}
36+
37+
static void labelledBlockEndsDoWhileBody(int n) {
38+
ErrorLoopStateJumps b = new ErrorLoopStateJumps();
39+
do {
40+
b.close(); // Expect: State Refinement Error
41+
n--;
42+
done: {
43+
break done;
44+
}
45+
} while (n > 0);
46+
}
47+
48+
// the break leaves the loop and the enclosing labelled block with b marked
49+
static void breakOutOfEnclosingBlock(int n) {
50+
ErrorLoopStateJumps b = new ErrorLoopStateJumps();
51+
out: {
52+
while (n > 0) {
53+
b.mark(); // Expect: State Refinement Error
54+
if (n == 3)
55+
break out;
56+
b.reset();
57+
n--;
58+
}
59+
}
60+
b.needOpen();
61+
}
62+
63+
// the finally closes b after the break, so b is closed after the loop
64+
static void breakThroughFinally() {
65+
ErrorLoopStateJumps b = new ErrorLoopStateJumps();
66+
while (true) {
67+
try {
68+
b.mark(); // Expect: State Refinement Error
69+
break;
70+
} finally {
71+
b.close();
72+
}
73+
}
74+
b.reset();
75+
}
76+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package testSuite;
2+
3+
import liquidjava.specification.Refinement;
4+
import liquidjava.specification.StateRefinement;
5+
import liquidjava.specification.StateSet;
6+
7+
// Each check of a loop body starts from the same facts: the values left by an earlier check must not leak into it
8+
@StateSet({"open", "closed"})
9+
public class ErrorLoopStatePasses {
10+
@StateRefinement(to = "open(this)")
11+
ErrorLoopStatePasses() {}
12+
13+
@StateRefinement(to = "closed(this)")
14+
void close() {}
15+
16+
@StateRefinement(from = "closed(this)", to = "open(this)")
17+
void reopen() {}
18+
19+
@StateRefinement(from = "open(this)", to = "closed(this)")
20+
@StateRefinement(from = "closed(this)", to = "open(this)")
21+
void toggle() {}
22+
23+
// the loop is never entered, so b is still open; x == 0 from the previous check must not make the exit impossible
24+
static void neverEntered() {
25+
ErrorLoopStatePasses b = new ErrorLoopStatePasses();
26+
int x = 100;
27+
while (x < 10) {
28+
b.close();
29+
x = 0;
30+
}
31+
b.reopen(); // Expect: State Refinement Error
32+
}
33+
34+
// in the first iteration y == 0; x == 7 from the previous check must not hide the error in the lambda
35+
static void lambdaInFirstIteration(int n) {
36+
ErrorLoopStatePasses t = new ErrorLoopStatePasses();
37+
int x = 0;
38+
while (n > 0) {
39+
t.toggle();
40+
final int y = x;
41+
Runnable r = () -> {
42+
@Refinement("_ == 7")
43+
int z = y; // Expect: Refinement Error
44+
};
45+
x = 7;
46+
n--;
47+
}
48+
}
49+
}

‎liquidjava-verifier/src/main/java/liquidjava/processor/context/Context.java‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
import java.util.*;
44
import liquidjava.rj_language.Predicate;
5+
import liquidjava.utils.Utils;
56
import spoon.reflect.declaration.CtElement;
67
import spoon.reflect.reference.CtTypeReference;
78

89
public class Context {
910
private Stack<List<RefinedVariable>> ctxVars;
1011
private List<RefinedFunction> ctxFunctions;
12+
private Map<String, List<RefinedFunction>> functionsBySimpleName;
1113
private List<RefinedVariable> ctxInstanceVars;
1214
private final List<RefinedVariable> ctxGlobalVars;
1315

@@ -24,6 +26,7 @@ private Context() {
2426
ctxVars = new Stack<>();
2527
ctxVars.add(new ArrayList<>());
2628
ctxFunctions = new ArrayList<>();
29+
functionsBySimpleName = new HashMap<>();
2730
ctxInstanceVars = new ArrayList<>();
2831
ctxGlobalVars = new ArrayList<>();
2932

@@ -129,6 +132,7 @@ public void restoreInstanceVariables() {
129132
public void reinitializeAllContext() {
130133
reinitializeContext();
131134
ctxFunctions = new ArrayList<>();
135+
functionsBySimpleName = new HashMap<>();
132136
aliases = new ArrayList<>();
133137
ghosts = new ArrayList<>();
134138
classStates = new HashMap<>();
@@ -374,8 +378,15 @@ public void variablesCombineFromIf(Predicate cond) {
374378

375379
// ---------------------- Functions ----------------------
376380
public void addFunctionToContext(RefinedFunction f) {
377-
if (!ctxFunctions.contains(f))
381+
if (!ctxFunctions.contains(f)) {
378382
ctxFunctions.add(f);
383+
functionsBySimpleName.computeIfAbsent(Utils.getSimpleName(f.getName()), n -> new ArrayList<>()).add(f);
384+
}
385+
}
386+
387+
/** The functions with this simple name, whether registered under it or, if specified externally, qualified */
388+
public List<RefinedFunction> getFunctionsWithSimpleName(String simpleName) {
389+
return functionsBySimpleName.getOrDefault(simpleName, List.of());
379390
}
380391

381392
public RefinedFunction getFunction(String name, String target, int size) {

‎liquidjava-verifier/src/main/java/liquidjava/processor/refinement_checker/LoopTypestates.java‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ void startIteration() {
7878
reachable = true;
7979
}
8080

81-
/** The loop may exit here, if this point is reachable (the condition of a do-while is not, after a return) */
81+
/**
82+
* The loop may exit here, through its condition, if this point is reachable (the condition of a do-while is not
83+
* when every path through the body returns)
84+
*/
8285
void recordExit() throws LJError {
8386
if (reachable)
8487
addCurrentStates(exit);
@@ -93,8 +96,8 @@ void recordContinue() throws LJError {
9396
}
9497

9598
/**
96-
* Where the body ends, a continue joins the end of the body: from here, each object is in a state it may be in at
97-
* either
99+
* Marks the end of the body, where the paths through its continues join the path through its last statement: from
100+
* here on, each object is in a state it may be in at the end of the body or at any of the continues
98101
*
99102
* @param completes
100103
* whether the end of the body can be reached without a continue
@@ -128,9 +131,18 @@ boolean reachedFixpoint() {
128131
return !grown;
129132
}
130133

131-
/** After the loop, each tracked object is in a state it may be in where the loop exits */
134+
/**
135+
* After the loop, each tracked object is in a state it may be in where the loop exits. Java rejects code after a
136+
* loop that never exits, so no exit state means the states were not followed: the object is havocked instead, so
137+
* that this can never make the code after the loop pass
138+
*/
132139
void finishLoop() {
133-
exit.forEach(this::setStates);
140+
exit.forEach((name, states) -> {
141+
if (states.isEmpty())
142+
checker.forgetChanges(name, new Predicate(), loop);
143+
else
144+
setStates(name, states);
145+
});
134146
}
135147

136148
private Map<String, Set<State>> emptyStates() {

‎liquidjava-verifier/src/main/java/liquidjava/processor/refinement_checker/RefinementTypeChecker.java‎

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -634,12 +634,15 @@ private boolean isUninformativeCondition(Predicate conditionRefinement, CtExpres
634634
* must be discarded at the join.
635635
*
636636
* <p>
637+
* A statement that a labelled {@code break} leaves completes normally, since execution goes on after it.
638+
*
639+
* <p>
637640
* Not currently handled (treated conservatively as completing normally): {@code switch} where every case exits,
638-
* labeled {@code break}/{@code continue} targets, {@code try}/{@code catch}/{@code finally} flow, and infinite
639-
* loops such as {@code while (true)}. Extending this list only tightens precision.
641+
* {@code try}/{@code catch}/{@code finally} flow, and infinite loops such as {@code while (true)}. Extending this
642+
* list only tightens precision.
640643
*/
641644
boolean canCompleteNormally(CtStatement statement) {
642-
if (statement == null)
645+
if (statement == null || isLeftByLabelledBreak(statement))
643646
return true;
644647
if (statement instanceof CtReturn<?> || statement instanceof CtThrow || statement instanceof CtBreak
645648
|| statement instanceof CtContinue)
@@ -658,6 +661,11 @@ boolean canCompleteNormally(CtStatement statement) {
658661
return true;
659662
}
660663

664+
private static boolean isLeftByLabelledBreak(CtStatement statement) {
665+
return statement.getLabel() != null && statement.getElements(new TypeFilter<>(CtBreak.class)).stream()
666+
.anyMatch(jump -> jump.getTargetLabel() != null && jump.getLabelledStatement() == statement);
667+
}
668+
661669
@Override
662670
public void visitCtTry(CtTry tryBlock) {
663671
tryChecker.visitTry(tryBlock);
@@ -736,9 +744,10 @@ public void visitCtContinue(CtContinue continueStatement) {
736744
*/
737745
private void visitLoop(CtLoop loop, Consumer<LoopTypestates> iteration) {
738746
List<RefinedVariable> pathVariables = vcChecker.getPathVariables();
739-
// a field may also change in a method the loop calls, out of sight here, so fields are only havocked
740-
Set<String> locals = changedIn(loop).stream().filter(name -> !isField(name))
741-
.collect(Collectors.toCollection(LinkedHashSet::new));
747+
// a field may also change in a method the loop calls, out of sight here, so fields are only havocked; and
748+
// typestates are only followed through jumps whose states are recorded, else they are havocked too
749+
Set<String> locals = followsJumps(loop) ? changedIn(loop).stream().filter(name -> !isField(name))
750+
.collect(Collectors.toCollection(LinkedHashSet::new)) : Set.of();
742751
LoopTypestates states = new LoopTypestates(this, loop, locals);
743752
havocChangedIn(loop, states.tracked());
744753
loops.push(states);
@@ -753,24 +762,35 @@ private void visitLoop(CtLoop loop, Consumer<LoopTypestates> iteration) {
753762
}
754763

755764
/**
756-
* Checks iterations of a loop from the typestates at its head until they stop changing. Only the last iteration
757-
* checked keeps its diagnostics: an earlier one starts from fewer states, so an error it throws, which stops the
758-
* check, is also an error from the final states.
765+
* Checks iterations of a loop from the typestates at its head until they stop changing. Every iteration starts from
766+
* the same facts: those before it, with what the loop changes havocked, and only the head typestates differ, since
767+
* what an iteration adds to the context is dropped after it (its variables and instances are scoped to it, and its
768+
* path conditions are restored). So an earlier iteration starts from fewer states than the last one, and an error
769+
* it throws, which stops the check, is also an error from the final states. Only the last iteration keeps the
770+
* errors recorded without stopping it (e.g. in a lambda).
759771
*/
760772
private void checkUntilFixpoint(LoopTypestates states, Consumer<LoopTypestates> iteration) {
761773
List<RefinedVariable> pathVariables = vcChecker.getPathVariables();
762774
while (true) {
763775
Set<LJError> errors = new LinkedHashSet<>(diagnostics.getErrors());
764-
states.startIteration();
765-
iteration.accept(states);
776+
context.enterContext();
777+
try {
778+
states.startIteration();
779+
iteration.accept(states);
780+
} finally {
781+
context.exitContext();
782+
vcChecker.replacePathVariables(pathVariables);
783+
}
766784
if (states.reachedFixpoint())
767785
return;
768786
diagnostics.getErrors().retainAll(errors);
769-
vcChecker.replacePathVariables(pathVariables);
770787
}
771788
}
772789

773-
/** The loop exits where its condition is false: never if it has none, or if it is {@code true} */
790+
/**
791+
* Records the states where the loop exits through its condition, which are those where the condition is false: none
792+
* if there is no condition or it is {@code true}
793+
*/
774794
private void recordExit(LoopTypestates states, CtExpression<Boolean> condition) {
775795
if (condition == null)
776796
return;
@@ -792,14 +812,43 @@ private void scanBody(CtLoop loop, LoopTypestates states) {
792812
states.endBody(canCompleteNormally(loop.getBody()));
793813
}
794814

795-
/** The loop being checked that {@code jump} leaves, if any (an unlabeled break in a switch leaves the switch) */
815+
/** The loop being checked that {@code jump} leaves, if any */
796816
private Optional<LoopTypestates> loopLeftBy(CtLabelledFlowBreak jump) {
797-
CtElement target = jump.getTargetLabel() != null ? jump.getLabelledStatement()
798-
: jump.getParent(e -> e instanceof CtLoop || jump instanceof CtBreak
799-
&& (e instanceof CtSwitch<?> || e instanceof CtSwitchExpression<?, ?>));
817+
CtElement target = jumpTarget(jump);
800818
return loops.stream().filter(states -> states.loop() == target).findFirst();
801819
}
802820

821+
/** The statement a break or continue leaves: its label's, else the innermost loop, or switch for a break */
822+
private static CtElement jumpTarget(CtLabelledFlowBreak jump) {
823+
if (jump.getTargetLabel() != null)
824+
return jump.getLabelledStatement();
825+
return jump.getParent(e -> e instanceof CtLoop || (jump instanceof CtBreak && isSwitch(e)));
826+
}
827+
828+
private static boolean isSwitch(CtElement element) {
829+
return element instanceof CtSwitch<?> || element instanceof CtSwitchExpression<?, ?>;
830+
}
831+
832+
/**
833+
* Whether the states at every jump in the loop are recorded where it goes: each jump goes to a loop or leaves a
834+
* switch (not a labelled block, which joins no states), and runs no finally block or resource closing on its way,
835+
* which could change states after they are recorded
836+
*/
837+
private static boolean followsJumps(CtLoop loop) {
838+
return loop.getElements(new TypeFilter<>(CtLabelledFlowBreak.class)).stream().allMatch(jump -> {
839+
CtElement target = jumpTarget(jump);
840+
return (target instanceof CtLoop || isSwitch(target)) && !leavesTryWithCleanup(jump, target);
841+
});
842+
}
843+
844+
private static boolean leavesTryWithCleanup(CtLabelledFlowBreak jump, CtElement target) {
845+
for (CtTry left = jump.getParent(CtTry.class); left != null
846+
&& left.hasParent(target); left = left.getParent(CtTry.class))
847+
if (left.getFinalizer() != null || left instanceof CtTryWithResource)
848+
return true;
849+
return false;
850+
}
851+
803852
/** Havocs what the loop may change, except the {@code tracked} typestates */
804853
private void havocChangedIn(CtLoop loop, Set<String> tracked) {
805854
for (String name : changedIn(loop))
@@ -865,8 +914,8 @@ private static boolean isField(String name) {
865914
private boolean mayChangeState(CtInvocation<?> invocation) {
866915
String name = invocation.getExecutable().getSimpleName();
867916
int arity = invocation.getArguments().size();
868-
return context.getCtxFunctions().stream().anyMatch(f -> Utils.getSimpleName(f.getName()).equals(name)
869-
&& f.getArguments().size() == arity && f.getAllStates().stream().anyMatch(ObjectState::hasTo));
917+
return context.getFunctionsWithSimpleName(name).stream().anyMatch(
918+
f -> f.getArguments().size() == arity && f.getAllStates().stream().anyMatch(ObjectState::hasTo));
870919
}
871920

872921
/**

0 commit comments

Comments
 (0)