Skip to content

fix #98: replace deleteOnExit() with explicit cleanup to prevent temp file leak - #112

Open
phaneendra-injarapu wants to merge 2 commits into
apache:masterfrom
phaneendra-injarapu:fix/issue-98-remove-deleteonexit-temp-file-leak
Open

fix #98: replace deleteOnExit() with explicit cleanup to prevent temp file leak#112
phaneendra-injarapu wants to merge 2 commits into
apache:masterfrom
phaneendra-injarapu:fix/issue-98-remove-deleteonexit-temp-file-leak

Conversation

@phaneendra-injarapu

@phaneendra-injarapu phaneendra-injarapu commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #98

Problem

DefaultDownloadManager.download() registered every temporary download file with
File.deleteOnExit(). Each call adds an entry to the JVM-wide static
java.io.DeleteOnExitHook set, and entries are never removed during the JVM's
lifetime — they are only iterated at shutdown. Over many invocations this caused:

  • Unbounded memory growth — one retained entry, with its path string, per downloaded file
  • Shutdown-time bookkeeping proportional to the number of downloads

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 that
directory 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 retainTempFile flag is set only once the file is the one reachable through
the cache. The merged finally block deletes the temp file straight away
otherwise — covering ConnectionException / AuthenticationException (connect
failure), TransferFailedException / ResourceDoesNotExistException /
AuthorizationException (transfer failure), and losing a race to cache the same
URL. Failed downloads therefore contribute nothing at all at shutdown.

3. New DefaultDownloadManager.cleanup() for long-lived JVMs

Deletes 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 DownloadManager
interface is unchanged, so no existing implementor breaks.

Cache correctness

cache.put() is replaced with a guarded putIfAbsent: a concurrent download of
the 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 File that no longer existed; shouldDownloadAgainWhenTheCachedFileWasDeleted
fails without this change.

Refactor

The separate try/catch blocks around wagon.connect() and wagon.get() are
merged into one. A connected flag keeps wagon.disconnect() limited to the
case where connect() actually succeeded, and transfer listeners are now removed
even when connecting failed — they are added before the connect attempt, so
leaving them attached leaked listeners onto a Wagon that may be reused.

Tests

DefaultDownloadManagerTest grows from 14 to 21 tests. The new ones:

  • shouldDownloadIntoTheSharedTempDirectoryInsteadOfRegisteringDeleteOnExit — the
    download lands under the single shared directory, the structural property that
    keeps shutdown bookkeeping constant
  • shouldDeleteTempFileOnConnectionFailure — no file is left behind under the
    download directory after a ConnectionException
  • shouldDeleteTempFileOnTransferFailure — uses an EasyMock Capture to get the
    exact File passed to wagon.get() and asserts it no longer exists after a
    TransferFailedException
  • shouldDownloadAgainWhenTheCachedFileWasDeleted — a stale cache entry is
    replaced rather than returned
  • shouldDeleteDownloadedFilesOnCleanupcleanup() removes the files and the
    directory
  • shouldStillBeUsableAfterCleanup — the manager recreates its directory and
    keeps working after cleanup()
  • shouldNotDeleteTheFilesOfAnotherManagerOnCleanupcleanup() is isolated
    per instance

All 87 tests in the module pass, and no maven-shared-io-downloads-* directory
remains after the test JVM exits, which exercises the shutdown hook end to end.

Notes for reviewers

  • Downloaded files still occupy disk for the lifetime of the manager — the caller
  • shouldDeleteTempFileOnConnectionFailure — no file is left behind under the
    download directory after a ConnectionException
  • shouldDeleteTempFileOnTransferFailure — uses an EasyMock Capture to get the
    exact File passed to wagon.get() and asserts it no longer exists after a
    TransferFailedException
  • shouldDownloadAgainWhenTheCachedFileWasDeleted — a stale cache entry is
    replaced rather than returned
  • shouldDeleteDownloadedFilesOnCleanupcleanup() removes the files and the
    directory
  • shouldStillBeUsableAfterCleanup — the manager recreates its directory and
    keeps working after cleanup()
  • shouldNotDeleteTheFilesOfAnotherManagerOnCleanupcleanup() is isolated
    per instance

All 87 tests in the module pass, and no maven-shared-io-downloads-* directory
remains after the test JVM exits, which exercises the shutdown hook end to end.

Notes for reviewers

  • Downloaded files still occupy disk for the lifetime of the manager — the caller
    reads the returned File, so it has to exist. Bounding disk usage (LRU or size
    cap) would be a separate feature.
  • URLLocation still calls deleteOnExit() per fetched URL. Same pattern,
    different class; left out to keep this PR to one issue.

Contribution Checklist

  • Your pull request should address just one issue, without pulling in other changes.
  • Write a pull request description that is detailed enough to understand what the pull request does, how, and why.
  • Each commit in the pull request should have a meaningful subject line and body.
    remains after the test JVM exits, which exercises the shutdown hook end to end.

Notes for reviewers

  • Downloaded files still occupy disk for the lifetime of the manager — the caller
    reads the returned File, so it has to exist. Bounding disk usage (LRU or size
    cap) would be a separate feature.
  • URLLocation still calls deleteOnExit() per fetched URL. Same pattern,
    different class; left out to keep this PR to one issue.

Contribution Checklist

  • Your pull request should address just one issue, without pulling in other changes.
  • Write a pull request description that is detailed enough to understand what the pull request does, how, and why.
  • Each commit in the pull request should have a meaningful subject line and body.
  • Write unit tests that match behavioral changes, where the tests fail if the changes to the runtime are not applied.
    (7 new tests; each fails against the previous implementation.)
  • Run mvn verify to make sure basic checks pass.
    (Test suite verified: 87 tests, 0 failures, main sources compile at
    --release 8. A full mvn verify could not be completed locally because
    the environment has no access to Maven Central for the spotless/checkstyle
    plugins; relying on CI for those.)
  • I hereby declare this contribution to be licenced under the Apache License Version 2.0, January 2004

…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
phaneendra-injarapu force-pushed the fix/issue-98-remove-deleteonexit-temp-file-leak branch from c85989e to 1c9d65b Compare July 6, 2026 19:42
@elharo
elharo requested a review from Copilot August 1, 2026 11:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 thread src/main/java/org/apache/maven/shared/io/download/DefaultDownloadManager.java Outdated
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DefaultDownloadManager: temp file leak via deleteOnExit() accumulation

2 participants