UnnecessaryCloseInTryWithResources: only remove a trailing close of a Closeable - #978
Draft
martinfrancois wants to merge 4 commits into
Draft
Conversation
… 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.
…the positional rule with a Closeable
4 tasks
martinfrancois
force-pushed
the
fix/unnecessary-close-resource-lifetime
branch
from
August 16, 2026 20:30
5dabc8b to
9845c40
Compare
martinfrancois
marked this pull request as draft
August 17, 2026 08:08
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Suggested review order: 14 of 52 (Score: 7)
Review first: openrewrite/rewrite#8445
What's changed?
UnnecessaryCloseInTryWithResourcesno longer deletes aclose()call that a later statement in the same try body can observe. It now removes a call only when all four of these hold.close()call this same rule removes, so a run of repeated trailing
close()calls is removed as a whole.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.
doNotRemoveCloseOfResourceThatIsNotClosedFirstcovers this, and its "closed first" names the same resource as "declared last" here.SemanticallyEqual.areEqual, rather than by simple name,which is how the code on
maincompares them.java.io.Closeable.close(), or an override of it, whose javadoc states "If thestream is already closed then invoking this method has no effect" (
Closeable#close()). TheMethodMatchersignature is narrowed fromjava.lang.AutoCloseable close()tojava.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: aclose()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
descriptionfield is rewritten to state this behaviour, and the recipe's row insrc/main/resources/META-INF/rewrite/recipes.csvcarries the same new text.Before
Actual after the recipe
Using the recipe on current
main.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
maincompile, so the problem shows only at run time: the input runs without error, the output throwsAssertionError, becauseresource.closedis read while the resource is still open. Onmainthe recipe deletes aclose()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 makesclose()run once where the source asked for twice: the contract ofAutoCloseable#close(), the signaturemain'sMethodMatcheris 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
mainthrows aNullPointerExceptionon valid Java 9 code that uses a field as a resource, such astry (this.writer) { writer.close(); }. A field access is neither a variable declaration nor a plain identifier, so the visitor storesnullas that resource's name and then callsequalson 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 incommon-static-analysis.yml, so this bug reaches everyone who runs that composite recipe.Confirmed real-world execution
ColumnSerializerUtils.javaat5d84de19.org.openrewrite.recipe:rewrite-static-analysis:2.41.0.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:
description. The old text was inaccurate in three ways: it namedAutoCloseable, where the contract the recipe depends on is the narrowerjava.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.close()calls in a single visit of the try statement, rather than one call per recipe cycle, which is whatmaindoes too.removeRepeatedTrailingClosecovers a try body that ends in two consecutivescanner.close()calls, under the defaultRecipeSpec, 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.Closeableand still break the contract that interface documents, for example by counting every call toclose()or by throwing on the second one. An explicitclose()on such a type is still removed, exactly as onmain.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 implementAutoCloseablewithout implementingCloseable. Some such types define an idempotentclose(), but the contract does not require idempotency, so the recipe would still change how oftenclose()runs for the remaining types. Reverting means editing one string literal, the signature passed to theMethodMatcherconstructor, and deletingdoNotRemoveCloseOfResourceThatIsNotCloseable, 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:doNotRemoveCloseWhenResourceStateIsObservedAfterwardsdoNotRemoveCloseFollowedByOtherStatementsdoNotRemoveCloseOfResourceThatIsNotClosedFirstdoNotRemoveCloseOfResourceThatIsNotCloseabledoNotRemoveCloseWithCommentThese 2 tests pass either way and cover behavior this change preserves:
removeRepeatedTrailingClose, described aboveremoveCloseOfReferencedResource, which shows that thetry (scanner)form still has its trailingclose()removedNo 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
./gradlew buildlocally, and committed any resulting changes torecipes.csv