Skip to content

See state changes through aliases and forget mutable field initializers across methods - #393

Open
CatarinaGamboa wants to merge 2 commits into
mainfrom
fix/337-alias-field-state
Open

CatarinaGamboa wants to merge 2 commits into
mainfrom
fix/337-alias-field-state

Conversation

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator

Fixes #337, fixes #340.

Two false negatives found in the real-code examples, one commit each.

#337: state changes through an alias. Res b = a; records b == a, but b.close() gave b a new closed version that was no longer equal to a, so a.read() passed. After a state change, every other variable of a related type that the solver proves refers to the same object now points to the new version (AuxStateHandler.updateAliases, relatedTypes). Reassigning b = new Res() breaks the alias, so closing b afterwards leaves a alone.

Fixtures: ErrorStateChangeThroughAlias (plain alias, alias of an alias, alias created by a later assignment, close inside an if) and CorrectStateChangeThroughAlias (separate objects, reassignment, state-preserving calls, closing through either name in different branches).

#340: a mutable field read as its initializer. A field initializer's value stayed visible in every method. When a method (not a constructor) starts, each non-final field now keeps only its declared refinement (RefinementTypeChecker.forgetMutableFieldValues). Final fields keep their initializer, and assignments within the method are still tracked.

Fixtures: ErrorMutableFieldInitializer (the reproducer via q and this.q, a static non-final field, a non-final field holding an object with a state) and CorrectMutableFieldInitializer (final field, field refinement that guarantees the bound, assign-then-read, read in the constructor).

Existing fixtures changed: three field cases in CorrectDeferredCallbackState / ErrorDeferredCallbackState relied on a non-final field's initializer state inside a method. Their calls moved into the constructors, where that state really holds, so they still test that creating a callback does not run it.

Not covered:

  • Aliasing through method arguments: parameter state is unknown and callee state changes are not propagated to the caller.
  • Variables that may or may not alias (e.g. two parameters) still keep separate states.
  • updateGhostField (state-tracked int field writes) does not update aliases.
  • A final field holding an object with a state still assumes its initializer state, although another method can change it. Treating it like a non-final field breaks classes/state_field_initializer_correct (Fix Field Initialization #281), so it needs a design decision.
  • Within one method, a call that may write a field (this.setQ(x)) does not invalidate the value assigned earlier in the method (pre-existing).

Validation: mvn test passes all 414 tests (253 TestExamples cases).

🤖 Generated with Claude Code

CatarinaGamboa and others added 2 commits October 13, 2026 09:19
A typestate transition creates a new instance of the variable it was made
through (`b#2` with `closed(b#2)`), but other variables bound to the same
object (`Res b = a;` gives `b#1 == a#1`) kept their old instance, so `a`
was still considered open after `b.close()`.

After a transition, every other reference variable of a related type whose
current instance is provably equal to the target's previous instance gets a
new instance equal to the transitioned one, so the new state is seen through
all must-aliases (including aliases of aliases and aliases created by a later
assignment). Reassigning the alias (`b = new Res()`) breaks the proved
equality, so later changes through it no longer affect the original.
Variables that may, but need not, alias the target keep their state.

Fixes #337

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A field initializer's instance stayed the field's last instance for the
whole class, so every method read a non-final field as if it still held
its initializer, although any other method (e.g. a setter) may have
reassigned it. Passing such a field to a refined parameter was accepted.

On entry to a method (constructors are unchanged), each non-final field
with a recorded value gets a fresh instance carrying only its declared
refinement. Final fields keep their initializer. Assignments made inside
the method are still tracked as before.

The field cases of the deferred-callback fixtures relied on the
initializer state of a non-final field in a method; they now check that
state in the constructor, where it does hold, so they keep testing that
creating a callback does not run it.

Fixes #340

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

lgtm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant