Repository navigation
Compute loop typestates as a fixpoint over iterations - #434
CatarinaGamboa wants to merge 8 commits into
Conversation
|
[Codex review — blocking] A reachable loop body can be checked under an impossible state and its invalid call is accepted. I reproduced this on this PR head with a class that has
|
|
[Claude] Fixed in 63600f8. The loop fixpoint now gives each state set a Your reproducer now reports the error in the loop. |
A method with several state refinements, such as `mark()` from open to marked or from marked, was refused on an object that is open or marked: each entry state was checked on its own, and neither is provable. Only state-preserving calls accepted the union. When no single entry state is provable but their union is, allow the call and give the object the state of any alternative whose entry state it may be in: the target state of an alternative with one, and the unchanged object for an alternative without. This is needed for objects whose state after a loop is the union of the states the loop may leave them in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A loop checked one iteration from havocked receivers, so a state set inside the body was either forgotten or kept after the loop, depending on whether a method of that name with a state change was found: - Methods specified with @ExternalRefinementsFor are registered under their qualified name, so the lookup missed them and the state set in the body was kept after the loop and in later iterations (a reset after a loop that marks may find the reader unmarked; a read after a close in the previous iteration passed). - When found, the object's state was unknown in the body and after the loop, so a call valid in every iteration was refused (a read in a loop that reads and marks). Now the states of each local object the loop may change, among the finitely many states of its class, start as the states before the loop and grow with the states at the end of each checked iteration (the end of the body and the continues) until they stop changing. The body is checked from them, and after the loop the object is in a state it may be in where the loop exits (its condition is false, or a break). Earlier iterations start from fewer states, so their errors are errors of the last one too; the errors they record without stopping (e.g. in a lambda) are dropped, so each is reported once. Fields are still havocked, since a call may change them. The method lookup now also matches externally specified methods. Closes #427. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The entry, target and union checks of a state-changing call each substituted the arguments for the parameters in their own loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed 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>
Checking an element puts metadata on it (its refinement, target, receiver), and the receiver of `new T().m()` is reused when present. Unlike the context, metadata is not scoped, so a later check of a loop body reused a receiver that an earlier check had created in a lambda and dropped since, and reported `Not Found Error: Variable '#fresh_40'` instead of checking the call. Remove that metadata from the loop's elements before each check, so each computes it afresh as the first one does. The parts checked once before the loop, and the declarations of local classes, keep theirs. Also: the readable loop in leavesTryWithCleanup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A block left by a labelled break completes normally (since f9941ce), which fixes a join after an if that also passed on main: `if (c) { done: { b.close(); break done; } } b.read();`. The tests of loops with a break to a labelled block or through a finally pass through the havoc fallback, not the fixpoint: say so. Also: an unmodifiable list from the simple-name lookup, and the reason for the empty-exit guard, which does not rely on Java rejecting code after a loop that never exits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An object after @StateRefinement(to = "!open(this) && !closed(this)") is in no named state, so the loop head had no state for it and the body was checked from false. Each state set now has a none alternative in the loop fixpoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Find state-changing methods by simple name in the function list, without a second index in Context to keep in sync. - One statesOf with a withNone flag instead of three methods. - Record the next iteration's states in the fixpoint loop, not in each loop kind. - Compute each entry state once for a call from a union of entry states. - Shorter comments on the fixpoint and metadata removal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
63600f8 to
cb1a49d
Compare
|
[Codex re-review] The blocker I reported is fixed at I reviewed the subsequent simplification of the loop's next-iteration recording, method lookup, and state disjunction and found no new correctness issue. The full local verifier suite passes: 545 test cases, 0 failures or errors ( Small documentation follow-up: |
After a loop, an object whose class has an external spec keeps the state set inside the loop body, so a real bug passes (#427). The same loops over a class specified in the file have the opposite problem: their state is forgotten, so valid calls inside the loop are refused.
Problem
The same shape is a real bug in tabu-timetabling's
InputReader("Stream not marked", fixed in 5ea97d1): the inner loop that marks may stop at once, and thenreset()runs.With a class specified in the file, a call that every iteration allows is refused:
Cause
A loop was checked for one iteration, after havocking what it may change. An object was havocked only if the loop called a method of that name with a state change, looked up by simple name. Methods from
@ExternalRefinementsForare registered under their qualified name (java.io.BufferedReader.mark), so the lookup missed them and the state set in the body was kept, after the loop and in the next iteration. When the lookup did find them, the object's state was unknown in the whole body, hence the false error above. Fixing only the lookup would move external specs to that false error too (br.mark(100)above would be refused).Fix
Eight commits: the first two are the change, the third is a refactoring, the next four follow review, and the last simplifies the code.
mark()(from open to marked, or from marked) was refused on an object that is open or marked, since each entry state was checked alone. Now, when only their union is provable, the call is allowed and the object takes the state of any alternative whose entry state it may be in.continue), until the set stops changing. The body is then checked from those states, and after the loop the object is in a state it may be in where the loop exits (the condition is false, or abreak). For while, do-while, for and for-each, withbreakandcontinue(labelled or not) to a loop or out of a switch; a loop with any other jump falls back to havoc (below).breakto a labelled block, or one that runs afinallyor closes resources on its way out.breakleaves now completes normally. Before,if (c) { done: { b.close(); break done; } } b.read();passed, because the join after theifdropped the then branch.@StateRefinement(to = "!open(this) && !closed(this)"). Each set has anonealternative in the fixpoint, so such an object is tracked like one in a named state (before, its body was checked fromfalse, and a wrong call in it passed).Now:
Tests
classes/loop_state_external_error: the two reproducers of Soundness: after a loop, an object with an external spec keeps the state set inside the loop body #427, do-while, for-each,continue,break, nested loops, and the tabu-timetabling shape (a mark only in the inner loop).classes/loop_state_external_correct: mark and reset in the body, open or marked in every iteration, do-while, for-each,continue/break, nested loops, and the fix of the tabu-timetabling bug. This one also passes on main; it guards against a fix of the lookup alone, which would refuse it.CorrectLoopStateFixpoint(in-file class): the false error above and the same loop kinds.ErrorLoopStateFixpoint: a state reached only in the third iteration, and an error in a lambda in the loop reported once.ErrorLoopStatePasses: values from an earlier check must not leak into the next (a loop never entered, and a lambda error in the first iteration).CorrectLoopFreshReceivers/ErrorLoopFreshReceivers: a receiver created in an earlier check, e.g.() -> new T().close(), must not be reused (it gave a Not Found Error).ErrorLoopStateJumps: pins the havoc fallback for abreakto a labelled block (inside the body or around the loop) and through afinally.ErrorLabelledBlockInIf: the if-join fix (main accepts this program, so the test fails there).CorrectStateChangeFromUnion/ErrorStateChangeFromUnion: the first commit.CorrectLoopStateNone/ErrorLoopStateNone: an object in no state of its set before the loop, and one that reaches it in the body (both fail without the last commit).ErrorLoopPostState(Soundness: after a loop, the state is taken from the loop body as if it ran at least once #338) is still reported, andCorrectLoopPostStatestill passes.mvn test: 545 tests, 0 failures (rebased on main at f7f366c).Results
InputReader:bris a static field there, and LiquidJava does not track the state of static fields, even without loops. So the original file still passes. Withbrandfras locals and the method otherwise unchanged, the bug is reported atbr.reset(), and the fixed version passes.mvn testtook 202 s of CPU on main and 178 s on this branch. Both differences are within the noise.Closes #427.
🤖 Generated with Claude Code