Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,21 @@
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.sun.source.tree.AssignmentTree;
import com.sun.source.tree.BlockTree;
import com.sun.source.tree.ClassTree;
import com.sun.source.tree.ExpressionStatementTree;
import com.sun.source.tree.ExpressionTree;
import com.sun.source.tree.IdentifierTree;
import com.sun.source.tree.MemberSelectTree;
import com.sun.source.tree.MethodInvocationTree;
import com.sun.source.tree.MethodTree;
import com.sun.source.tree.NewClassTree;
import com.sun.source.tree.StatementTree;
import com.sun.source.tree.Tree;
import com.sun.source.tree.VariableTree;
import com.sun.source.util.TreePath;
import com.sun.source.util.TreeScanner;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
Expand All @@ -32,6 +41,8 @@
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.Name;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.lang.model.type.TypeKind;
Expand Down Expand Up @@ -1506,6 +1517,21 @@ private void checkReassignmentToField(Set<Obligation> obligations, AssignmentNod
return;
}
}
} else if (TreeUtils.isConstructor(enclosingMethodTree)) {
// Suppress if this is the first write to a private field in the constructor if we can
// conservatively guarantee no earlier field write or method call overwrites the field
Element enclosingClassElement =
TreeUtils.elementFromDeclaration(enclosingMethodTree).getEnclosingElement();
if (ElementUtils.isTypeElement(enclosingClassElement)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can this test fail? If it does, should an exception be thrown?

Element receiverElement = TypesUtils.getTypeElement(receiver.getType());
if (Objects.equals(enclosingClassElement, receiverElement)) {
VariableElement lhsElement = lhs.getElement();
if (lhsElement.getModifiers().contains(Modifier.PRIVATE)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method isFirstAssignmentToField is document to do this test, so move it into the method.

&& isFirstAssignmentToField(lhsElement, enclosingMethodTree, node.getTree())) {
return;
}
}
}
}

// Check that there is a corresponding CreatesMustCallFor annotation, unless this is
Expand Down Expand Up @@ -1625,6 +1651,126 @@ && varTrackedInObligations(obligations, (LocalVariableNode) receiver))
}
}

/**
* Determines whether the given assignment is the first write to a private field during a
* potential non-final owning field overwrite. This is used to suppress false positive resource
* leak warnings when it is safe to assume that a constructor initializes the field for the first
* time. This method returns {@code true} if: - the field is private, - it has no non-null inline
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Javadoc should be more clear that this method conservatively returns false unless it can prove that this is the first reassignment to a field.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the Javadoc.

* initializer, - it is not assigned in any instance initializer block, - the constructor does not
* use constructor chaining (this(...)), - there are no earlier assignment before the given field
* assignment that might write to the field. - there are no method invocations before the given
* field assignment
*
* @param field The field being assigned
* @param constructor The constructor where the assignment appears
* @param currentAssignment The actual assignment tree being analyzed
* @return true if this assignment can be safely considered the first and only one during
* construction
*/
private boolean isFirstAssignmentToField(
VariableElement field, MethodTree constructor, Tree currentAssignment) {
@Nullable TreePath constructorPath = cmAtf.getPath(constructor);
ClassTree classTree = TreePathUtil.enclosingClass(constructorPath);
String fieldName = field.getSimpleName().toString();

// Disallow non-null inline initializer
for (Tree member : classTree.getMembers()) {
if (member instanceof VariableTree) {
VariableTree var = (VariableTree) member;
if (var.getName().contentEquals(fieldName) && var.getInitializer() != null) {
if (var.getInitializer().getKind() != Tree.Kind.NULL_LITERAL) {
return false;
}
}
}
}

// Disallow assignment in instance initializer blocks
for (Tree member : classTree.getMembers()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For efficiency, merge this into the above loop, like (5) and (6) are handled by a single loop.

if (member instanceof BlockTree) {
BlockTree block = (BlockTree) member;
if (block.isStatic()) continue;
boolean[] found = {false};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is found an array? Why not just a boolean?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — I used the array to work around Java's requirement that variables accessed from within inner classes be effectively final. I've updated the code to use AtomicBoolean instead, which takes care of the issue. Thanks!

block.accept(
new TreeScanner<Void, Void>() {
@Override
public Void visitAssignment(AssignmentTree node, Void unused) {
ExpressionTree lhs = node.getVariable();
Name lhsName =
lhs instanceof MemberSelectTree
? ((MemberSelectTree) lhs).getIdentifier()
: lhs instanceof IdentifierTree ? ((IdentifierTree) lhs).getName() : null;
if (lhsName != null && lhsName.contentEquals(fieldName)) {
found[0] = true;
}
return super.visitAssignment(node, unused);
}
},
null);
if (found[0]) {
return false;
}
}
}
// Check constructor initialization chaining
if (callsThisConstructor(constructor)) {
return false;
}
// Check for earlier assignments to the same field, or any method calls other than super
List<? extends StatementTree> statements = constructor.getBody().getStatements();
for (StatementTree stmt : statements) {
if (!(stmt instanceof ExpressionStatementTree)) {
continue;
}
ExpressionTree expr = ((ExpressionStatementTree) stmt).getExpression();
// Stop when we reach the current assignment
if (expr == currentAssignment) {
break;
}
// Prior assignment to the same field
if (expr instanceof AssignmentTree) {
ExpressionTree lhs = ((AssignmentTree) expr).getVariable();
Name lhsName = null;
if (lhs instanceof MemberSelectTree) {
lhsName = ((MemberSelectTree) lhs).getIdentifier();
} else if (lhs instanceof IdentifierTree) {
lhsName = ((IdentifierTree) lhs).getName();
}
if (lhsName != null && lhsName.contentEquals(fieldName)) {
return false; // Unsafe: field already assigned earlier
}
}
// Any method call before assignment (except super constructor)
if (expr instanceof MethodInvocationTree
&& !TreeUtils.isSuperConstructorCall((MethodInvocationTree) expr)) {
return false; // Unsafe: method may write to field internally
}
}
return true;
}

/**
* Returns true if the given constructor delegates to another constructor in the same class using
* a {@code this(...)} call as its first statement.
*
* @param constructor the constructor method to check
* @return {@code true} if the constructor starts with a {@code this(...)} call, {@code false}
* otherwise
*/
private boolean callsThisConstructor(MethodTree constructor) {
List<? extends StatementTree> statements = constructor.getBody().getStatements();
if (!statements.isEmpty()) {
StatementTree firstStmt = statements.get(0);
if (firstStmt instanceof ExpressionStatementTree) {
ExpressionTree expr = ((ExpressionStatementTree) firstStmt).getExpression();
if (expr instanceof MethodInvocationTree) {
return TreeUtils.isThisConstructorCall((MethodInvocationTree) expr);
}
}
}
return false;
}

/**
* Checks that the method that encloses an assignment is marked with @CreatesMustCallFor
* annotation whose target is the object whose field is being re-assigned.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.checkerframework.checker.test.junit;

import java.io.File;
import java.util.List;
import org.checkerframework.checker.resourceleak.ResourceLeakChecker;
import org.checkerframework.framework.test.CheckerFrameworkPerDirectoryTest;
import org.junit.runners.Parameterized.Parameters;

/**
* Tests for validating safe suppression of resource leak warnings when a private field is
* initialized for the first time inside a constructor.
*
* <p>These tests check that the checker allows first-time constructor-based assignments (when safe)
* and continues to report reassignments or leaks in all other cases (e.g., after method calls,
* initializer blocks, etc.).
*/
public class ResourceLeakFirstInitConstructorTest extends CheckerFrameworkPerDirectoryTest {
public ResourceLeakFirstInitConstructorTest(List<File> testFiles) {
super(
testFiles,
ResourceLeakChecker.class,
"resourceleak-firstinitconstructor",
"-AwarnUnneededSuppressions",
"-encoding",
"UTF-8");
}

@Parameters
public static String[] getTestDirs() {
return new String[] {"resourceleak-firstinitconstructor"};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Test: Field is initialized in one constructor and reassigned in another via this() chaining.
// Expected: Warning in constructor and open() due to reassignments.

import java.io.FileInputStream;
import org.checkerframework.checker.calledmethods.qual.*;
import org.checkerframework.checker.mustcall.qual.*;

@InheritableMustCall({"close"})
class ConstructorChainingLeak {
private @Owning FileInputStream s;

public ConstructorChainingLeak() throws Exception {
this(42);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a test like this one but where the this(42) call is replaced by a call to a super constructor? I'd like to at least codify the behavior in that scenario (I think it would be safe not to warn in that constructor, but it's okay if there's an FP now).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a test case for a constructor that calls super().
The suppression logic already handles super constructor calls correctly, so no false positive is reported solely due to the super() call.

// :: error: (required.method.not.called)
s = new FileInputStream("test.txt");
}

private ConstructorChainingLeak(int x) throws Exception {
s = new FileInputStream("test.txt");
}

// :: error: (missing.creates.mustcall.for)
public void open() {
try {
// :: error: (required.method.not.called)
s = new FileInputStream("test.txt");
} catch (Exception e) {
}
}

@EnsuresCalledMethods(value = "this.s", methods = "close")
public void close() {
try {
s.close();
} catch (Exception e) {
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Test: Field is explicitly initialized to null and assigned in constructor.
// Expected: No warning in constructor, warning in open().

import java.io.FileInputStream;
import org.checkerframework.checker.calledmethods.qual.*;
import org.checkerframework.checker.mustcall.qual.*;

@InheritableMustCall({"close"})
class ExplicitNullInitializer {
private @Owning FileInputStream s = null;

public ExplicitNullInitializer() {
try {
s = new FileInputStream("test.txt");
} catch (Exception e) {
}
}

// :: error: (missing.creates.mustcall.for)
public void open() {
try {
// :: error: (required.method.not.called)
s = new FileInputStream("test.txt");
} catch (Exception e) {
}
}

@EnsuresCalledMethods(value = "this.s", methods = "close")
public void close() {
try {
s.close();
} catch (Exception e) {
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Test: Field has no initializer and is first assigned in constructor.
// Expected: No warning in constructor, warning in open().

import java.io.FileInputStream;
import org.checkerframework.checker.calledmethods.qual.*;
import org.checkerframework.checker.mustcall.qual.*;

@InheritableMustCall({"close"})
class FirstAssignmentInConstructor {
private @Owning FileInputStream s;

public FirstAssignmentInConstructor() {
try {
s = new FileInputStream("test.txt"); // no warning
} catch (Exception e) {
}
}

// :: error: (missing.creates.mustcall.for)
public void open() {
try {
// :: error: (required.method.not.called)
s = new FileInputStream("test.txt");
} catch (Exception e) {
}
}

@EnsuresCalledMethods(value = "this.s", methods = "close")
public void close() {
try {
s.close();
} catch (Exception e) {
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Test: Field has a non-null inline initializer and is reassigned in constructor and open().
// Expected: Warning in constructor and in open().

import java.io.FileInputStream;
import org.checkerframework.checker.calledmethods.qual.*;
import org.checkerframework.checker.mustcall.qual.*;

@InheritableMustCall({"close"})
class InlineInitializerLeak {
private @Owning FileInputStream s = new FileInputStream("test.txt");

public InlineInitializerLeak() throws Exception {
// :: error: (required.method.not.called)
s = new FileInputStream("test.txt");
}

// :: error: (missing.creates.mustcall.for)
public void open() {
try {
// :: error: (required.method.not.called)
s = new FileInputStream("test.txt");
} catch (Exception e) {
}
}

@EnsuresCalledMethods(value = "this.s", methods = "close")
public void close() {
try {
s.close();
} catch (Exception e) {
}
}
}
Loading
Loading