I first observed this behavior on our build server running Jenkins. We have two build jobs, building snapshot versions. Project A and B. Project B depends on A. Always dealing with snapshots. Jenkins ran the standard mvn clean install command. Now, when both jobs were running in parallel on Jenkins, sometimes project B failed during compilation or running unit tests with weird ClassNotFound exceptions. The missing classes were from project A, of course.

Recently, I found another incarnation of this problem - even within one project: I was building PMD with the maven-pmd-plugin overriding its dependencies to use the current building snapshot of PMD. PMD itself is a multi-module build. First, pmd-core is built, after that pmd-test (which depends on pmd-core). pmd-core was successful including the m-pmd-p run. pmd-test was also built successfully, but m-pmd-p plugin failed: It couldn’t find the internal ruleset files anymore, that are included in pmd-core.

After much digging and debugging, I think, I know now what happend: We have the maven process, that is building PMD. Since it also executes m-pmd-p, it has a file handle to pmd-core located in the local repository somewhere in ~/.m2/repository. The install step of pmd-core will override this jar file in the local repository. But the file handle is still valid, just the contents of the file changed. Even though, there were no changes in pmd-core, the generated jar file is somewhat different (at least the build timestamp changes). It seems, that the underlying implementation to read from the jar file (which is actually a zip file) as problems reading the zip entries again. I assume, that the position of the zip entries (which might be cached) changed and reading the resources failed, thus leading to RuleSetNotFoundExceptions and a failed build. PMD now closes the ruleset files after usage, so they need to be read on every execution of PMD. This is also true for this case, where PMD is executed multiple times during one Java VM execution due to the multi-module maven build.

When I stopped the build in the debugger just before PMD tried to read the ruleset and I replaced the jar file in my local repository with the one before pmd-core was rebuilt (just a simple cp - file inode stays the same), the build continued until after pmd-java was built. Now the same problem repeated with pmd-java, which was the second dependency the m-pmd-p was using.

I tried to reproduce the “problem”, but I couldn’t get the URLClassLoader to return null for the resource. There seems to be more caching ongoing. Anyway, here’s the test program, that at least errors when the resource is read, because the jar file changed. The program creates a jar file with one resource, creates a classloader, that can load this resource, overwrites the jar file and tries to load the resource again:

package org.adangel.java;

import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.Random;
import java.util.jar.*;

public class JarFileHandles {
    private static final String CONTENT = "Test Content";

    public static void main(String[] args) throws Exception {
        File tmpFile = File.createTempFile(JarFileHandles.class.getSimpleName(), ".jar");
        tmpFile.deleteOnExit();

        URLClassLoader classLoader = new URLClassLoader(new URL[] { tmpFile.toURI().toURL() }, null);

        System.out.println("TempFile: " + tmpFile);

        String name = "somedir/file1.txt";
        writeJarFile(tmpFile, name, false);
        verifyEntryExists(classLoader, name);

        writeJarFile(tmpFile, name, true);
        verifyEntryExists(classLoader, name);
    }

    private static void writeJarFile(File file, String name, boolean additionalEntries) throws FileNotFoundException, IOException {
        JarOutputStream out = new JarOutputStream(new FileOutputStream(file));
        out.setMethod(JarEntry.DEFLATED);
        out.setLevel(9);

        if (additionalEntries) {
            for (int i = 0; i < 300; i++) {
                byte[] junk = new byte[1024];
                new Random().nextBytes(junk);
                JarEntry junkEntry = new JarEntry("junk-before-" + i);
                out.putNextEntry(junkEntry);
                out.write(junk);
                out.closeEntry();
            }
        }

        JarEntry ze = new JarEntry(name);
        ze.setSize(CONTENT.length());
        out.putNextEntry(ze);
        out.write(CONTENT.getBytes(StandardCharsets.ISO_8859_1));

        out.flush();
        out.close();
    }
    
    private static void verifyEntryExists(ClassLoader classLoader, String name) throws IOException {
        URL resource = classLoader.getResource(name);
        if (resource == null) {
            throw new AssertionError("Resource " + name + " not found!");
        }
        URLConnection connection = resource.openConnection();
        connection.setUseCaches(false);
        InputStream inputStream = connection.getInputStream();
        readVerify(inputStream);
        inputStream.close();
    }

    private static void readVerify(InputStream stream) throws IOException {
        byte[] data = new byte[1024];
        String content = "";
        int ret = stream.read(data);
        while (ret != -1) {
            content += new String(data, 0, ret, StandardCharsets.ISO_8859_1);
            ret = stream.read(data);
        }
        if (!CONTENT.equals(content)) {
            throw new AssertionError("Read Error");
        }
    }
}

And here’s there corresponding output:

TempFile: /tmp/JarFileHandles2770127016726201243.jar
Exception in thread "main" java.util.zip.ZipException: invalid distance too far back
    at java.util.zip.InflaterInputStream.read(InflaterInputStream.java:164)
    at java.io.FilterInputStream.read(FilterInputStream.java:133)
    at java.io.FilterInputStream.read(FilterInputStream.java:107)
    at org.adangel.java.JarFileHandles.readVerify(JarFileHandles.java:68)
    at org.adangel.java.JarFileHandles.verifyEntryExists(JarFileHandles.java:61)
    at org.adangel.java.JarFileHandles.main(JarFileHandles.java:25)