fix #98: replace deleteOnExit() with explicit cleanup to prevent temp file leak - #112
Open
phaneendra-injarapu wants to merge 2 commits into
Conversation
…t temp file leak Each download() call previously registered a new entry in the JVM-wide DeleteOnExitHook static set via File.deleteOnExit(). Over many invocations this caused unbounded memory growth and degraded JVM shutdown performance. Fix: - Remove deleteOnExit() entirely. - On failure (connect or transfer): delete the temp file immediately in the finally block using a boolean success flag, so no orphaned files remain. - On success: register one shutdown hook per manager instance (not per download) that deletes all cached temp files at JVM exit. This is O(instances) rather than O(downloads). - Merge the two separate try-catch blocks (connect + get) into one with a boolean connected flag so disconnect() is only called when connect succeeded, preserving existing test expectations. Add two new tests: - shouldDeleteTempFileOnConnectionFailure: verifies no new download-*.tmp files remain in the temp directory after a connection failure. - shouldDeleteTempFileOnTransferFailure: captures the temp File via EasyMock and asserts it no longer exists after a transfer failure.
phaneendra-injarapu
force-pushed
the
fix/issue-98-remove-deleteonexit-temp-file-leak
branch
from
July 6, 2026 19:42
c85989e to
1c9d65b
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
This PR addresses #98 by removing per-download File.deleteOnExit() usage in DefaultDownloadManager (which accumulates JVM-wide DeleteOnExitHook entries) and replacing it with explicit cleanup behavior to prevent temp file leaks.
Changes:
- Remove
deleteOnExit()from the download temp-file creation path and add immediate deletion on failure. - Add a per-instance shutdown hook to delete cached download temp files at JVM shutdown.
- Add new unit tests asserting temp files are deleted on connection and transfer failures.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/main/java/org/apache/maven/shared/io/download/DefaultDownloadManager.java |
Reworks temp file lifecycle management (no deleteOnExit()), adds shutdown cleanup, and refactors connect/get flow with success/connected flags. |
src/test/java/org/apache/maven/shared/io/download/DefaultDownloadManagerTest.java |
Adds regression tests to ensure temp files are cleaned up on connection/transfer failures. |
Suppressed comments (1)
src/main/java/org/apache/maven/shared/io/download/DefaultDownloadManager.java:179
- On failure the temp file is deleted via File.delete(), but the return value is ignored. If deletion fails (Windows file locks, AV scanners, etc.), the method will still leak the temp file silently. Consider using Files.deleteIfExists(...) and recording any IOException in the MessageHolder so failures are observable.
if (!success && downloaded != null) {
downloaded.delete();
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
156
to
162
| wagon.get(remotePath, downloaded); | ||
|
|
||
| // cache this for later download requests to the same instance... | ||
| cache.put(url, downloaded); | ||
|
|
||
| success = true; | ||
| return downloaded; |
Comment on lines
181
to
183
| // ensure the Wagon instance is closed out properly (only if connect succeeded) | ||
| if (wagon != null && connected) { | ||
| try { |
Comment on lines
+378
to
+390
| File tempDir = new File(System.getProperty("java.io.tmpdir")); | ||
| Set<String> filesBefore = listDownloadTempFiles(tempDir); | ||
|
|
||
| try { | ||
| downloadManager.download(tempFile.toURI().toASCIIString(), new DefaultMessageHolder()); | ||
| fail("should have failed to connect wagon."); | ||
| } catch (DownloadFailedException e) { | ||
| assertTrue(ExceptionUtils.getStackTrace(e).contains("ConnectionException")); | ||
| } | ||
|
|
||
| Set<String> filesAfter = listDownloadTempFiles(tempDir); | ||
| filesAfter.removeAll(filesBefore); | ||
| assertTrue(filesAfter.isEmpty(), "Temp file must be deleted immediately when connection fails, not leaked"); |
…d hook Every download registered its temp file with File.deleteOnExit(), which adds an entry to the JVM-wide static java.io.DeleteOnExitHook set. Those entries are never removed while the JVM runs, so memory and shutdown bookkeeping grew with the number of downloads. Downloads now land in one lazily created temp directory that a single shutdown hook removes recursively, so the retained state is constant regardless of how many files are downloaded. Each manager instance uses its own subdirectory, and no static field references an individual download, so a discarded manager and its cache stay collectible. Temp files that will not be returned to the caller are deleted straight away: a failed connect or transfer, and losing a race to cache the same URL. Failed downloads therefore leave nothing behind at shutdown. Add DefaultDownloadManager.cleanup() so a long-lived JVM, such as a Maven daemon or an embedded build, can release the files without waiting for exit. It is added on the implementation only; the DownloadManager interface is unchanged. Replace cache.put() with a guarded putIfAbsent(): a concurrent download of the same URL returns the file already published in the cache and discards its redundant copy, while a stale entry whose file has since been deleted is replaced by the fresh download rather than handed back. Merge the try/catch blocks around wagon.connect() and wagon.get(). A connected flag keeps wagon.disconnect() to the case where connect() succeeded, and transfer listeners are removed even when connecting failed, since they are attached before the connect attempt. Add seven tests covering the shared download directory, immediate deletion on connect and transfer failure, stale cache replacement, and cleanup() semantics including reuse afterwards and isolation between instances.
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.
Fixes #98
Problem
DefaultDownloadManager.download()registered every temporary download file withFile.deleteOnExit(). Each call adds an entry to the JVM-wide staticjava.io.DeleteOnExitHookset, and entries are never removed during the JVM'slifetime — they are only iterated at shutdown. Over many invocations this caused:
Fix
deleteOnExit()is removed. Cleanup is now handled by three mechanisms:1. One temp directory, one shutdown hook (O(1) instead of O(downloads))
All downloads land in a single lazily-created temp directory
(
maven-shared-io-downloads-*), and exactly one shutdown hook removes thatdirectory recursively. Nothing is registered per file, so the amount of retained
state stays constant no matter how many files are downloaded. Each manager
instance gets its own subdirectory, which is what allows per-instance cleanup
(below) without touching another instance's files. No static field ever
references an individual download, so a discarded manager and its cache remain
collectible.
2. Immediate deletion when a file will not be returned
A
retainTempFileflag is set only once the file is the one reachable throughthe cache. The merged
finallyblock deletes the temp file straight awayotherwise — covering
ConnectionException/AuthenticationException(connectfailure),
TransferFailedException/ResourceDoesNotExistException/AuthorizationException(transfer failure), and losing a race to cache the sameURL. Failed downloads therefore contribute nothing at all at shutdown.
3. New
DefaultDownloadManager.cleanup()for long-lived JVMsDeletes this manager's downloads and empties its cache, so a Maven daemon or
embedded build can release the files without waiting for JVM exit. It is
optional, and it is added on the implementation only — the
DownloadManagerinterface is unchanged, so no existing implementor breaks.
Cache correctness
cache.put()is replaced with a guardedputIfAbsent: a concurrent download ofthe same URL now returns the file already published in the cache (which callers
may already be reading) and discards its own redundant copy, while a stale
cache entry — one whose file has since been deleted from disk — is replaced by
the fresh download instead of being handed back. The previous code could return
a
Filethat no longer existed;shouldDownloadAgainWhenTheCachedFileWasDeletedfails without this change.
Refactor
The separate
try/catchblocks aroundwagon.connect()andwagon.get()aremerged into one. A
connectedflag keepswagon.disconnect()limited to thecase where
connect()actually succeeded, and transfer listeners are now removedeven when connecting failed — they are added before the connect attempt, so
leaving them attached leaked listeners onto a
Wagonthat may be reused.Tests
DefaultDownloadManagerTestgrows from 14 to 21 tests. The new ones:shouldDownloadIntoTheSharedTempDirectoryInsteadOfRegisteringDeleteOnExit— thedownload lands under the single shared directory, the structural property that
keeps shutdown bookkeeping constant
shouldDeleteTempFileOnConnectionFailure— no file is left behind under thedownload directory after a
ConnectionExceptionshouldDeleteTempFileOnTransferFailure— uses an EasyMockCaptureto get theexact
Filepassed towagon.get()and asserts it no longer exists after aTransferFailedExceptionshouldDownloadAgainWhenTheCachedFileWasDeleted— a stale cache entry isreplaced rather than returned
shouldDeleteDownloadedFilesOnCleanup—cleanup()removes the files and thedirectory
shouldStillBeUsableAfterCleanup— the manager recreates its directory andkeeps working after
cleanup()shouldNotDeleteTheFilesOfAnotherManagerOnCleanup—cleanup()is isolatedper instance
All 87 tests in the module pass, and no
maven-shared-io-downloads-*directoryremains after the test JVM exits, which exercises the shutdown hook end to end.
Notes for reviewers
shouldDeleteTempFileOnConnectionFailure— no file is left behind under thedownload directory after a
ConnectionExceptionshouldDeleteTempFileOnTransferFailure— uses an EasyMockCaptureto get theexact
Filepassed towagon.get()and asserts it no longer exists after aTransferFailedExceptionshouldDownloadAgainWhenTheCachedFileWasDeleted— a stale cache entry isreplaced rather than returned
shouldDeleteDownloadedFilesOnCleanup—cleanup()removes the files and thedirectory
shouldStillBeUsableAfterCleanup— the manager recreates its directory andkeeps working after
cleanup()shouldNotDeleteTheFilesOfAnotherManagerOnCleanup—cleanup()is isolatedper instance
All 87 tests in the module pass, and no
maven-shared-io-downloads-*directoryremains after the test JVM exits, which exercises the shutdown hook end to end.
Notes for reviewers
reads the returned
File, so it has to exist. Bounding disk usage (LRU or sizecap) would be a separate feature.
URLLocationstill callsdeleteOnExit()per fetched URL. Same pattern,different class; left out to keep this PR to one issue.
Contribution Checklist
remains after the test JVM exits, which exercises the shutdown hook end to end.
Notes for reviewers
reads the returned
File, so it has to exist. Bounding disk usage (LRU or sizecap) would be a separate feature.
URLLocationstill callsdeleteOnExit()per fetched URL. Same pattern,different class; left out to keep this PR to one issue.
Contribution Checklist
(7 new tests; each fails against the previous implementation.)
mvn verifyto make sure basic checks pass.(Test suite verified: 87 tests, 0 failures, main sources compile at
--release 8. A fullmvn verifycould not be completed locally becausethe environment has no access to Maven Central for the spotless/checkstyle
plugins; relying on CI for those.)