Skip to content

UnnecessaryCloseInTryWithResources: only remove a trailing close of a Closeable - #978

Draft
martinfrancois wants to merge 4 commits into
openrewrite:mainfrom
martinfrancois:fix/unnecessary-close-resource-lifetime
Draft

UnnecessaryCloseInTryWithResources: only remove a trailing close of a Closeable#978
martinfrancois wants to merge 4 commits into
openrewrite:mainfrom
martinfrancois:fix/unnecessary-close-resource-lifetime

Conversation

@martinfrancois

@martinfrancois martinfrancois commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Suggested review order: 14 of 52 (Score: 7)
Review first: openrewrite/rewrite#8445

What's changed?

UnnecessaryCloseInTryWithResources no longer deletes a close() call that a later statement in the same try body can observe. It now removes a call only when all four of these hold.

  1. The call is the last statement of the try body, or every statement after it is itself a close()
    call this same rule removes, so a run of repeated trailing close() calls is removed as a whole.
  2. The receiver is the resource declared last. A try-with-resources statement closes its resources
    in the reverse of the order in which they are declared (JLS 14.20), so that one is closed first, at the position the explicit call already occupies. Removing the call on any earlier declared resource would move its close to a later point and change the order in which resources are closed. doNotRemoveCloseOfResourceThatIsNotClosedFirst covers this, and its "closed first" names the same resource as "declared last" here.
  3. The receiver matches that resource under SemanticallyEqual.areEqual, rather than by simple name,
    which is how the code on main compares them.
  4. The method is java.io.Closeable.close(), or an override of it, whose javadoc states "If the
    stream is already closed then invoking this method has no effect" (Closeable#close()). The MethodMatcher signature is narrowed from java.lang.AutoCloseable close() to java.io.Closeable close() for the reason given below.

Two comment situations also stop the removal, so a comment attached to a removable close() call, or to the end of the try body, is not deleted with the call: a close() call carrying a comment of its own, on the lines directly above it, is left in place; and if a comment sits between the last statement of the try body and that body's closing brace, the try statement is left untouched in full.

The recipe's description field is rewritten to state this behaviour, and the recipe's row in src/main/resources/META-INF/rewrite/recipes.csv carries the same new text.

Before

try (Resource resource = new Resource()) {
    resource.close();
    if (!resource.closed) {
        throw new AssertionError();
    }
}

Actual after the recipe

Using the recipe on current main.

try (Resource resource = new Resource()) {
    if (!resource.closed) {
        throw new AssertionError();
    }
}

Expected after the recipe

(unchanged)

The recipe leaves the input above exactly as written.

What's your motivation?

Recipe: org.openrewrite.staticanalysis.UnnecessaryCloseInTryWithResources.

Both the input and the output produced by main compile, so the problem shows only at run time: the input runs without error, the output throws AssertionError, because resource.closed is read while the resource is still open. On main the recipe deletes a close() call on any declared resource, at any position in the try body, and puts nothing in its place, so every statement that used to run after the explicit call now runs while the resource is still open.

Two further symptoms. For a resource whose close() is not idempotent, for example one that counts its own calls, the deletion makes close() run once where the source asked for twice: the contract of AutoCloseable#close(), the signature main's MethodMatcher is built with, does not require idempotence, which is why this branch narrows it. A comment written above the deleted call is deleted with it as well.

Separately, the recipe on main throws a NullPointerException on valid Java 9 code that uses a field as a resource, such as try (this.writer) { writer.close(); }. A field access is neither a variable declaration nor a plain identifier, so the visitor stores null as that resource's name and then calls equals on it. This branch reads only the last declared resource and returns the try statement unchanged when that resource is neither of those two forms.

Reproduced on v2.39.0, v2.40.0 and current main. The recipe is listed in common-static-analysis.yml, so this bug reaches everyone who runs that composite recipe.

Confirmed real-world execution

Druid closes its writer and smoosher before loading the file produced by them. The released recipe deletes both calls, so the load runs before the metadata file exists or reads stale metadata.

Anything in particular you'd like reviewers to focus on?

Two points:

  • The new description. The old text was inaccurate in three ways: it named AutoCloseable, where the contract the recipe depends on is the narrower java.io.Closeable; it inferred redundancy from "try-with-resources closes each declared resource" alone, which claims it for a call on any declared resource, not only on the one declared last; and it never mentioned the last-statement condition.
  • The visitor walks backwards from the last statement and removes the whole run of consecutive redundant close() calls in a single visit of the try statement, rather than one call per recipe cycle, which is what main does too. removeRepeatedTrailingClose covers a try body that ends in two consecutive scanner.close() calls, under the default RecipeSpec, which allows exactly one changing cycle, so it would fail if a second cycle were needed.

One limitation remains. A type can implement java.io.Closeable and still break the contract that interface documents, for example by counting every call to close() or by throwing on the second one. An explicit close() on such a type is still removed, exactly as on main.

Have you considered any alternatives or workarounds?

One alternative is to keep the matcher signature java.lang.AutoCloseable close(), which keeps the recipe active for custom resource types that implement AutoCloseable without implementing Closeable. Some such types define an idempotent close(), but the contract does not require idempotency, so the recipe would still change how often close() runs for the remaining types. Reverting means editing one string literal, the signature passed to the MethodMatcher constructor, and deleting doNotRemoveCloseOfResourceThatIsNotCloseable, the test that pins the narrower signature down. Nothing else here depends on the choice.

Any additional context

This change adds 7 tests to UnnecessaryCloseInTryWithResourcesTest. Without the code change in this pull request, these 5 tests fail:

  • doNotRemoveCloseWhenResourceStateIsObservedAfterwards
  • doNotRemoveCloseFollowedByOtherStatements
  • doNotRemoveCloseOfResourceThatIsNotClosedFirst
  • doNotRemoveCloseOfResourceThatIsNotCloseable
  • doNotRemoveCloseWithComment

These 2 tests pass either way and cover behavior this change preserves:

  • removeRepeatedTrailingClose, described above
  • removeCloseOfReferencedResource, which shows that the try (scanner) form still has its trailing close() removed

No existing test expectation changed: the test file has 196 added lines and no deleted lines, so the three tests already in it, including the @DocumentExample, are untouched.

This change was prepared with AI assistance (Claude Code). I reviewed the code, the tests and this description.

Checklist

… Closeable

The recipe removed an explicit `close()` from any top level position in the
try body, which changes what the code does. Statements after the call used
to observe a closed resource and now observe an open one, so an intentional
early close is silently deferred to the end of the block. `AutoCloseable`
explicitly allows `close()` to have visible effects when invoked a second
time, so replacing an explicit call with the implicit one can change close
counts and the exceptions that are thrown or suppressed. Resources are also
closed in reverse order of declaration, so dropping the close of anything
but the last declared resource reorders the closes, and removing the call
stranded any comment attached to it.

The call is now removed only when it sits at the end of the body, it
closes the last declared resource, the receiver resolves to that resource,
no comment is attached to it or trailing it, and the resource is a
`java.io.Closeable`, whose contract is that closing an already closed
resource has no effect. Everything else is left alone.

A run of repeated trailing closes is collapsed in one pass, so the recipe
still finishes in a single cycle. The recipe description was rewritten,
because the old wording named `AutoCloseable`, claimed every declared
resource qualified and did not mention the trailing position, none of
which is true now. `recipes.csv` embeds that description and was
regenerated to match.

Two things for a reviewer to weigh. The recipe deliberately no longer
touches a resource that implements only `AutoCloseable`, because such a
close is not required to be idempotent and nothing in the type tells us
whether a second call is observable. Narrowing the matcher to
`java.io.Closeable` also relies on type attribution, so an unattributed
resource is left unchanged rather than guessed at. The existing tests,
including the `@DocumentExample`, still pass with their original
expectations.
@martinfrancois
martinfrancois force-pushed the fix/unnecessary-close-resource-lifetime branch from 5dabc8b to 9845c40 Compare August 16, 2026 20:30
@martinfrancois
martinfrancois marked this pull request as draft August 17, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants