fix #98: replace deleteOnExit() with explicit cleanup to prevent temp file leak - #112
Conversation
c85989e to
1c9d65b
Compare
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.
| wagon.get(remotePath, downloaded); | ||
|
|
||
| // cache this for later download requests to the same instance... | ||
| cache.put(url, downloaded); | ||
|
|
||
| success = true; | ||
| return downloaded; |
| // ensure the Wagon instance is closed out properly (only if connect succeeded) | ||
| if (wagon != null && connected) { | ||
| try { |
| 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"); |
elharo
left a comment
There was a problem hiding this comment.
need to run mvn spotless:apply
…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.
97b61dd to
f8db21e
Compare
| /** Removes the current download root. Called only by the shutdown hook. */ | ||
| private static void deleteDownloadRoot() { | ||
| File root; | ||
| synchronized (DefaultDownloadManager.class) { |
There was a problem hiding this comment.
I'm worried about this synchronization. This synchronizes the variable but is it still possible that two threads are going to try to delete the same directory at the same time?
There was a problem hiding this comment.
The synchronized block isn't guarding the deletion - it only publishes the downloadRoot field, which is written under the same class lock in downloadRoot(), so the hook doesn't read a stale value. I've reworded the comment to say that.
One overlap is possible, and it's harmless: cleanup() running on another thread while the hook deletes the root. The hook can't race itself, since it's registered once and the JVM runs it on a single thread. Both paths now use FileUtils.deleteQuietly, which ignores failures, so the loser of that race either finds the entry already gone or leaves onedirectory to the OS temp sweep - the fallback this class already relies on. Nothing throws.
| * | ||
| * @param path the file or directory to delete. | ||
| */ | ||
| private static void deleteRecursively(Path path) { |
There was a problem hiding this comment.
Let's not reinvent the wheel. There are existing methods to do this in various places. Maybe maven-shared-utils and definitely Apache commons. Probably other places too. This functionality is really hard to get right.
There was a problem hiding this comment.
Agreed, removed. Both call sites now use FileUtils.deleteQuietly from commons-io, which is already a compile-scope dependency here (URLLocation uses it). I checked 2.22.0 does what this code needs: it removes the tree, doesn't follow symbolic links (tested with links to a file and to a directory outside the tree both targets survived), and returns false rather than throwing when the file is missing.
I did add one call to go with it. The hook runs at JVM exit, when the class loader that defined this class may be closed - a Maven plugin realm is, at the end of a build. FileUtils can't be loaded at that point, so the hook has to touch it earlier; it now does, when the hook is registered. Without that call the regression test fails with ClassNotFoundException: org.apache.commons.io.FileUtils, so it's covered. Full suite passes.
f8db21e to
8b76056
Compare
8b76056 to
9e1dccf
Compare
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.
Two properties of this hook are less obvious than they look, and each has a
dedicated test:
recreated if it disappears behind our back (a temp-dir sweeper such as
systemd-tmpfiles, or macOS periodic cleanup). Registering a hook per rootwould reintroduce exactly the accumulation this issue is about, and a
Threadper entry is heavier than the string
DeleteOnExitHookretained. AshutdownHookAttemptedguard registers once, and the hook reads the currentroot when it runs rather than capturing one.
class loader that defined this class may already be closed — which is what
Maven does to a plugin class realm at the end of a build. A class the hook
only needs at that point could no longer be resolved, and the hook would die
with
NoClassDefFoundErrorand delete nothing.FileUtils.deleteQuietly()istherefore replaced by a small
deleteRecursively()built onDirectoryStreamand
Files.deleteIfExists(), deleting depth-first and treating symbolic linksas leaves (
NOFOLLOW_LINKS) so nothing outside the tree is touched.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.addShutdownHook()catchesSecurityExceptionalongsideIllegalStateException,so a restrictive policy makes cleanup fall back to the OS temp sweeper instead of
throwing an unchecked exception out of
download().Tests
DefaultDownloadManagerTestgrows from 20 to 31 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
shouldRegisterAtMostOneShutdownHookHoweverManyDownloadsAndManagers— 25managers, repeated downloads and
cleanup()calls add no hooksshouldNotRegisterAnotherShutdownHookWhenTheRootIsRemovedBehindOurBack— 25simulated sweeps of the root recreate it and keep downloading, still on one hook
shouldDeleteNestedDirectoriesOnCleanupWithoutFollowingSymbolicLinks— whatevera wagon leaves in the directory is removed, and a link's target outside the tree
survives
shouldDeleteTheRootWithoutClassesThatAShutdownHookCouldNoLongerLoad— loads themanager in a class loader where commons-io does not exist and runs the hook's
body, proving the delete path needs nothing the hook could fail to resolve
The two hook-count tests read a package-private
registeredShutdownHooks()counter. Asserting on
java.io.DeleteOnExitHookorjava.lang.ApplicationShutdownHooksdirectly would need--add-opens java.base/java.lang, which this module does not configure for Surefire, so theclass exposes the count instead.
All 31
DefaultDownloadManagerTesttests pass, and nomaven-shared-io-downloads-*directory remains after the test JVM exits, whichexercises the shutdown hook end to end.
Notes for reviewers
shutdown hook running library code does;
deleteOnExit()did not, since itretains only strings. In Maven that means one plugin class realm stays reachable
for the life of the JVM once something downloads. The hook's inherited context
class loader is cleared, which removes the other reference along that path, but
the remaining one cannot be avoided while keeping a hook at all. It is one
reference per class loader instead of one entry per downloaded file, so it is
still a large net reduction — flagging it because it is a real change in kind,
not because it is believed to be a problem. Dropping the hook entirely and
relying on
cleanup()plus the OS temp sweeper is the alternative, at the costof no longer cleaning up after a plain
mvnrun.reads the returned
File, so it has to exist. Bounding disk usage (LRU or sizecap) would be a separate feature.
cleanup()is documented as unsafe to call while a download is in flight onanother thread, since that download writes into the directory being removed.
URLLocationstill callsdeleteOnExit()per fetched URL. Same pattern,different class, and it sits behind the public
tempFileDeleteOnExitconstructor flag, so changing it is an API-semantics decision; left out to keep
this PR to one issue. Happy to open a follow-up.
Contribution Checklist
(11 new tests. The two hook-count tests and the class-loading test were each
verified by mutation: restoring the per-root hook registration fails the
first two, and putting
FileUtils.deleteQuietly()back in the hook pathfails the third with the
NoClassDefFoundErrordescribed above.)mvn verifyto make sure basic checks pass.(Partially verified locally. Spotless, Checkstyle and RAT all pass —
spotless:checkreports 41 files clean with the cache cleared, Checkstylereports 0 violations, RAT 0 unapproved licences — and main and test sources
compile at
--release 8. A fullmvn verifycould not run in thisenvironment: the enforcer requires Maven 3.9+ and only 3.8.5 is available,
and Surefire 3.5.6 needs
plexus-utils:1.1, which is not in the localrepository and cannot be fetched. Tests were therefore run through a JUnit
Platform launcher against the same compiled classes. Relying on CI for the
full lifecycle.)