skip to content

A test suite that injects directories with JUnit's @TempDir passes on Linux but fails on Windows CI with an IOException saying the temp directory could not be deleted. How do you diagnose and fix that?

level: seniorimportance: should knowfreq 22%

answer

  1. Windows blocks delete on open handle; Linux unlinks
  2. message lists the undeletable entry
  3. Files.lines / walk / list must be closed
  4. MappedByteBuffer keeps the lock until GC
  5. close the component in @AfterEach, not CleanupMode.NEVER

basics

~20 s

Almost always an unclosed file handle: Windows refuses to delete files that are still open, Linux does not. Find the stream, channel, ZipFile, watch service or memory-mapped buffer the test or the code under test leaves open and close it, normally with try-with-resources. Read-only file attributes are the other cause.

solid answer

~50 s

Treat it as a resource-leak signal, not a JUnit bug. Windows enforces mandatory file locking — an open handle blocks deletion — while Linux happily unlinks an open file, so the same leak is invisible there. Diagnosis: the exception message lists the entries that could not be deleted; that filename points straight at the culprit. Then look for anything opened and not closed on that file — `FileInputStream`/`FileOutputStream`, `Files.newBufferedReader`, a `FileChannel`, `ZipFile`/`FileSystems.newFileSystem`, a `RandomAccessFile`, a `WatchService`, a logger or embedded database still holding the file, or a background thread outliving the test. Special case: `MappedByteBuffer`. A memory-mapped region keeps the file locked until the buffer is garbage-collected, so closing the channel is not enough — avoid mapping inside `@TempDir`-based tests. Fixes: try-with-resources everywhere, close the component under test in `@AfterEach`, ensure your production API exposes a `close()`. Read-only attributes are the other cause. Do **not** paper over it with `CleanupMode.NEVER`.

code

java · 29 lines
java
// Leaks: the stream returned by Files.lines is never closed,
// so Windows cannot delete the file when @TempDir cleans up.
@Test
void leaky(@TempDir Path dir) throws IOException {
    Path f = Files.writeString(dir.resolve("data.txt"), "a\nb\n");
    long count = Files.lines(f).count();
    assertEquals(2, count);
}

// Fixed: try-with-resources closes the stream and the component,
// and @AfterEach runs before JUnit deletes the directory.
class IndexTest {
    private Indexer indexer; // implements AutoCloseable

    @AfterEach
    void closeIndexer() throws Exception {
        if (indexer != null) indexer.close();
    }

    @Test
    void fixed(@TempDir Path dir) throws IOException {
        Path f = Files.writeString(dir.resolve("data.txt"), "a\nb\n");
        try (var lines = Files.lines(f)) {
            assertEquals(2, lines.count());
        }
        indexer = new Indexer(dir);
        indexer.index();
    }
}

go deeper

for a junior

Recognise the shape: something is still holding the file open, and streams need try-with-resources.

for a middle

Explain the Linux/Windows deletion difference and walk from the filename in the error message to the unclosed resource.

for a senior

Diagnose systematically, insist the failure is a real leak, cover the exotic cases (mapped buffers, watch services, background threads, read-only attributes), and make the component's close() part of @AfterEach.

for a principal

Treat it as a class of defect: add a Windows lane to the matrix, enforce try-with-resources via static analysis, and require file-owning components to implement AutoCloseable so lifecycle is explicit rather than incidental.

## Why the platforms differ On Linux and macOS, deleting a file is unlinking a directory entry; the inode survives until the last descriptor closes, so `Files.delete` succeeds even with the file open. Windows applies mandatory sharing rules: a handle opened without `FILE_SHARE_DELETE` — which is what the JDK's ordinary file APIs do — blocks both deletion and rename. So a leaked handle is silent on Linux and fatal on Windows. This is why the failure looks like a platform quirk when it is really a latent defect that Windows happens to detect for you. The same asymmetry explains why the failure often appears in CI first: mixed-OS build matrices, or a Windows agent added later. ## Reading the failure Jupiter's cleanup walks the tree and, when it cannot remove entries, fails with an `IOException` reporting that it failed to delete the temp directory, listing the paths it could not remove. Two things to extract: 1. **Which file.** The listed entry names the resource that is still open — `index.db`, `report.csv`, `fixtures.zip`. That is your search key. 2. **Whose scope.** A method-scoped failure points at that one test; a class-scoped failure means something opened during the class never closed, often in `@BeforeAll`. ## The usual culprits - **Unclosed streams and readers.** `new FileInputStream(f)` or `Files.newBufferedReader(p)` without try-with-resources. Also streams wrapped in a decorator where only the decorator is closed on the happy path but an exception escapes first. - **`Files.lines` / `Files.list` / `Files.walk`.** These return streams backed by an open handle. They must be closed — use them inside try-with-resources. Forgetting this is one of the most common causes. - **`ZipFile` or a zip `FileSystem`.** Both hold the archive open until closed. - **The component under test.** An embedded database, an index writer, a file-backed cache, a log appender pointed at the temp directory. If it owns a file it must expose `close()`, and the test must call it in `@AfterEach`. - **`WatchService`.** Holds directory handles. - **Memory-mapped files.** `FileChannel.map` produces a `MappedByteBuffer` whose mapping is released only when the buffer is unreachable and collected. Closing the channel does not unmap it. There is no portable API to force unmapping, so the only reliable fix is to avoid mapping files that a test must delete, or to accept `CleanupMode.NEVER` for that one narrow test. - **Background threads.** An executor started by the code under test that keeps writing after the test method returns. If the test does not shut it down, the write can even race the deletion. ## Second cause: file attributes A file marked read-only, or a directory whose permissions the test changed (a deliberate "unwritable directory" negative test), can also defeat recursive deletion. The fix is to restore permissions in `@AfterEach` — set the file writable again before the scope ends — rather than leaving the filesystem in a state the cleanup cannot undo. ## Systematic remediation 1. **Make closing structural.** try-with-resources for every stream, reader, channel, zip and directory stream in both the test and the production path. 2. **Give the component an explicit lifecycle.** If the code under test opens files, it should implement `AutoCloseable`; the test then closes it in `@AfterEach`, which runs before Jupiter deletes the directory. That ordering is guaranteed. 3. **Reproduce locally.** Add a Windows job to the matrix, or approximate by asserting cleanliness yourself: in `@AfterEach`, attempt the recursive delete and fail loudly. That turns a platform-specific CI failure into a deterministic one everywhere. 4. **Do not disable cleanup.** `CleanupMode.NEVER` makes the red go away and keeps the leak, which in production means file descriptors accumulating and, on Windows deployments, files that cannot be rotated or replaced. The test found a real bug. ## What good answers emphasise The strong answer says three things: the platform difference and why it exists; that the failure is a leak signal with the offending filename in the message; and that the fix is closing resources — with memory-mapped buffers called out as the one case where closing is not sufficient. Reaching for `NEVER`, or dismissing it as "a Windows problem", is the weak answer.

  • Why does closing the FileChannel not always release a memory-mapped file?
    FileChannel.map returns a MappedByteBuffer whose native mapping is owned by the buffer, not the channel; the JDK releases it only when the buffer becomes unreachable and is collected, and there is no supported portable API to unmap it eagerly. So on Windows the file stays locked after channel.close() until a GC happens to reclaim the buffer. In tests that must delete the file, avoid mapping it, or isolate that test and accept that cleanup may need a different strategy.
  • How can you catch this class of leak on Linux, before a Windows agent finds it?
    Make the leak observable rather than relying on the OS. Options: add a Windows job to the CI matrix; assert in @AfterEach that the component under test reports itself closed; or use a resource-tracking wrapper or a JDK tool such as recording open file descriptors around the test. Static analysis for unclosed AutoCloseables and a review rule that every stream-returning Files method sits in try-with-resources catch most cases up front.

Linux lets you take the nameplate off a door while someone is still inside; Windows insists the room be empty first. The person left in the room is your unclosed handle.

saying these in an interview costs you the question

  • Calling it a JUnit or Windows bug rather than a resource leak in the test or production code
  • Switching to CleanupMode.NEVER or deleting the directory manually with a retry loop as the fix
  • Not knowing that Files.lines, Files.list and Files.walk return streams that must be closed
  • Believing closing the FileChannel always releases a MappedByteBuffer mapping
  • Thinking Linux would surface the same failure, so 'it must be something else'

context