You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by su...@apache.org on 2026/08/26 17:18:49 UTC

(groovy) branch GROOVY-12303 updated: GROOVY-12303: do not cache jar: URLs when checking source freshness

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch GROOVY-12303
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/GROOVY-12303 by this push:
     new ebd085eb9f GROOVY-12303: do not cache jar: URLs when checking source freshness
ebd085eb9f is described below

commit ebd085eb9f37f1ab13142fa3410f0ce83a61288b
Author: Daniel Sun <su...@apache.org>
AuthorDate: Thu Aug 27 02:17:53 2026 +0900

    GROOVY-12303: do not cache jar: URLs when checking source freshness
    
    ClassNodeResolver.isSourceNewer (and GroovyClassLoader.isSourceNewer)
    opened non-file source URLs with the default URLConnection cache, so a
    jar: lookup left the JAR mapped. On Windows that prevented JUnit from
    deleting the @TempDir used by
    ClassNodeResolverTest.nonFileSourceUrlUsesUrlConnectionForFreshness.
---
 ARCHITECTURE.md                                    |   4 +-
 src/main/java/groovy/lang/GroovyClassLoader.java   |   9 +-
 .../codehaus/groovy/control/ClassNodeResolver.java |  10 +-
 .../java/org/codehaus/groovy/util/URLStreams.java  |  48 ++++-
 .../groovy/lang/GroovyClassLoaderTest.groovy       |  26 +++
 .../groovy/control/ClassNodeResolverTest.groovy    | 128 +++++++++++++
 .../org/codehaus/groovy/util/URLStreamsTest.groovy | 197 +++++++++++++++++++++
 7 files changed, 408 insertions(+), 14 deletions(-)

diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 5352f60629..a22de66325 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -146,7 +146,9 @@ in this compilation unit, can still be represented. Class-loader lookup is
 the fallback when decompilation is disabled or the class exists only in
 memory. `NoClassDefFoundError` on that fallback is recovered by decompiling
 the still-present bytecode or by compiling a groovy source of the same
-name; an unrecoverable error is not cached as a miss.
+name; an unrecoverable error is not cached as a miss. When a groovy source
+is also found for a class that came from another class loader, timestamps
+are compared.
 
 ### Static type checker (phase 6)
 
diff --git a/src/main/java/groovy/lang/GroovyClassLoader.java b/src/main/java/groovy/lang/GroovyClassLoader.java
index a95e275e92..1a2ada5fd3 100644
--- a/src/main/java/groovy/lang/GroovyClassLoader.java
+++ b/src/main/java/groovy/lang/GroovyClassLoader.java
@@ -55,7 +55,6 @@ import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
 import java.net.URLClassLoader;
-import java.net.URLConnection;
 import java.net.URLDecoder;
 import java.nio.charset.StandardCharsets;
 import java.security.CodeSource;
@@ -875,6 +874,10 @@ public class GroovyClassLoader extends URLClassLoader {
 
     /**
      * Decides if the given source is newer than a class.
+     * <p>
+     * {@code file:} URLs use {@link File#lastModified()} because
+     * {@link java.net.URLConnection#getLastModified()} often reports {@code -1}.
+     * Other protocols use {@link URLStreams#getUncachedLastModified(URL)}.
      *
      * @param source the source we may want to compile
      * @param cls    the former class
@@ -896,9 +899,7 @@ public class GroovyClassLoader extends URLClassLoader {
             File file = new File(path);
             lastMod = file.lastModified();
         } else {
-            URLConnection conn = source.openConnection();
-            lastMod = conn.getLastModified();
-            conn.getInputStream().close();
+            lastMod = URLStreams.getUncachedLastModified(source);
         }
         long classTime = getTimeStamp(cls);
         return classTime + config.getMinimumRecompilationInterval() < lastMod;
diff --git a/src/main/java/org/codehaus/groovy/control/ClassNodeResolver.java b/src/main/java/org/codehaus/groovy/control/ClassNodeResolver.java
index 69490ba7ef..fee0a42e90 100644
--- a/src/main/java/org/codehaus/groovy/control/ClassNodeResolver.java
+++ b/src/main/java/org/codehaus/groovy/control/ClassNodeResolver.java
@@ -28,13 +28,13 @@ import org.codehaus.groovy.ast.decompiled.AsmDecompiler;
 import org.codehaus.groovy.ast.decompiled.AsmReferenceResolver;
 import org.codehaus.groovy.ast.decompiled.DecompiledClassNode;
 import org.codehaus.groovy.classgen.Verifier;
+import org.codehaus.groovy.util.URLStreams;
 import org.objectweb.asm.Opcodes;
 
 import java.io.File;
 import java.io.IOException;
 import java.net.MalformedURLException;
 import java.net.URL;
-import java.net.URLConnection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -505,6 +505,10 @@ public class ClassNodeResolver {
     /**
      * Returns true if the source in URL is newer than the class.
      * <p>
+     * {@code file:} URLs use {@link File#lastModified()} because
+     * {@link java.net.URLConnection#getLastModified()} often reports {@code -1}.
+     * Other protocols use {@link URLStreams#getUncachedLastModified(URL)}.
+     * <p>
      * NOTE: copied from GroovyClassLoader
      */
     private static boolean isSourceNewer(final URL source, final ClassNode cls) {
@@ -518,9 +522,7 @@ public class ClassNodeResolver {
                 File file = new File(path);
                 lastMod = file.lastModified();
             } else {
-                URLConnection conn = source.openConnection();
-                lastMod = conn.getLastModified();
-                conn.getInputStream().close();
+                lastMod = URLStreams.getUncachedLastModified(source);
             }
             return lastMod > getTimeStamp(cls);
         } catch (IOException e) {
diff --git a/src/main/java/org/codehaus/groovy/util/URLStreams.java b/src/main/java/org/codehaus/groovy/util/URLStreams.java
index e5141d7ee4..a48be2ab07 100644
--- a/src/main/java/org/codehaus/groovy/util/URLStreams.java
+++ b/src/main/java/org/codehaus/groovy/util/URLStreams.java
@@ -24,24 +24,62 @@ import java.net.URL;
 import java.net.URLConnection;
 
 /**
- * Utilities for opening URL streams with Groovy-specific defaults.
+ * Utilities for opening URL connections with Groovy-specific defaults.
+ * <p>
+ * Caching is disabled so {@code file:} and {@code jar:} URLs do not pin a
+ * file handle. On Windows an open handle prevents the file from being deleted
+ * or replaced.
  */
 public class URLStreams {
     private URLStreams() {
-
     }
 
     /**
      * Opens an {@link InputStream} reading from the given URL without
-     * caching the stream. This prevents file descriptor leaks when reading
-     * from file system URLs.
+     * caching the connection. This prevents file descriptor leaks when
+     * reading from file-system URLs.
      *
      * @param url the URL to connect to
      * @return an input stream reading from the URL connection
      */
     public static InputStream openUncachedStream(URL url) throws IOException {
+        return openUncachedConnection(url).getInputStream();
+    }
+
+    /**
+     * Returns {@link URLConnection#getLastModified()} for {@code url} without
+     * caching the connection, then closes it.
+     * <p>
+     * {@code jar:} URLs otherwise keep a cached {@link java.util.jar.JarFile}
+     * open after {@link URLConnection#getInputStream()} is closed, which on
+     * Windows locks the JAR until the JVM exits. A failure to open the stream
+     * (missing JAR entry, unreachable URL) is propagated so callers can treat
+     * the source as unreadable; a failure to close after a successful open is
+     * ignored because last-modified was already obtained.
+     *
+     * @param url the URL whose last-modified time is required
+     * @return the last-modified time in milliseconds since the epoch, or
+     *         {@code 0} if it is not known
+     * @throws IOException if the connection or stream cannot be opened
+     */
+    public static long getUncachedLastModified(URL url) throws IOException {
+        URLConnection urlConnection = openUncachedConnection(url);
+        // Open first so a missing jar: entry fails before last-modified pins the JAR.
+        InputStream in = urlConnection.getInputStream();
+        try {
+            return urlConnection.getLastModified();
+        } finally {
+            try {
+                in.close();
+            } catch (IOException ignore) {
+                // last-modified was already obtained
+            }
+        }
+    }
+
+    private static URLConnection openUncachedConnection(URL url) throws IOException {
         URLConnection urlConnection = url.openConnection();
         urlConnection.setUseCaches(false);
-        return urlConnection.getInputStream();
+        return urlConnection;
     }
 }
diff --git a/src/test/groovy/groovy/lang/GroovyClassLoaderTest.groovy b/src/test/groovy/groovy/lang/GroovyClassLoaderTest.groovy
index d4deff0901..2687d1d5a5 100644
--- a/src/test/groovy/groovy/lang/GroovyClassLoaderTest.groovy
+++ b/src/test/groovy/groovy/lang/GroovyClassLoaderTest.groovy
@@ -295,6 +295,32 @@ final class GroovyClassLoaderTest {
         '''
         assert c.name.startsWith('Script_')
     }
+
+    @Test
+    void isSourceNewerDisablesUrlConnectionCaches() {
+        def recorded = new Boolean[1]
+        def handler = new URLStreamHandler() {
+            @Override
+            protected URLConnection openConnection(URL u) {
+                new URLConnection(u) {
+                    @Override
+                    void connect() { connected = true }
+                    @Override
+                    void setUseCaches(boolean usecaches) {
+                        recorded[0] = usecaches
+                        super.setUseCaches(usecaches)
+                    }
+                    @Override
+                    long getLastModified() { System.currentTimeMillis() }
+                    @Override
+                    InputStream getInputStream() { InputStream.nullInputStream() }
+                }
+            }
+        }
+        URL url = new URL('gcltrack', 'localhost', -1, '/Foo.groovy', handler)
+        classLoader.isSourceNewer(url, String)
+        assert recorded[0] == Boolean.FALSE
+    }
 }
 
 //------------------------------------------------------------------------------
diff --git a/src/test/groovy/org/codehaus/groovy/control/ClassNodeResolverTest.groovy b/src/test/groovy/org/codehaus/groovy/control/ClassNodeResolverTest.groovy
index 8b92f4decb..b3a7e9f4e1 100644
--- a/src/test/groovy/org/codehaus/groovy/control/ClassNodeResolverTest.groovy
+++ b/src/test/groovy/org/codehaus/groovy/control/ClassNodeResolverTest.groovy
@@ -705,7 +705,78 @@ class ClassNodeResolverTest {
             def unit = new CompilationUnit(config, null, loader)
             def result = new ClassNodeResolver().resolveName(name, unit)
             assert result.sourceUnit
+            assert result.getSourceUnit().name.contains('Jarred.groovy')
         }
+        // jar: connections must not pin the file: Windows cannot delete an open JAR,
+        // which is what fails JUnit @TempDir cleanup for this test.
+        Files.delete(jar)
+    }
+
+    @Test
+    void nonFileSourceUrlDisablesUrlConnectionCaches() {
+        String name = 'cnr.recompile.Tracked'
+        writeBytes(tempDir, name.replace('.', '/'), stampedClassBytes(name.replace('.', '/'), '0'))
+        def handler = new CacheTrackingURLStreamHandler(System.currentTimeMillis())
+        URL sourceUrl = new URL('cnrtrack', 'localhost', -1, '/' + name.replace('.', '/') + '.groovy', handler)
+        parentLoader(tempDir).withCloseable { parent ->
+            def config = configWith([:])
+            def loader = new GroovyClassLoader(parent, config)
+            loader.resourceLoader = { String filename ->
+                filename == name ? sourceUrl : null
+            } as GroovyResourceLoader
+            def unit = new CompilationUnit(config, null, loader)
+            def result = new ClassNodeResolver().resolveName(name, unit)
+            assert result.sourceUnit
+        }
+        assert handler.useCaches == Boolean.FALSE
+        assert handler.lastModifiedCalled
+        assert handler.inputStreamClosed
+    }
+
+    @Test
+    void olderNonFileSourceKeepsTheExistingClass() {
+        String name = 'cnr.recompile.TrackedOld'
+        writeBytes(tempDir, name.replace('.', '/'), stampedClassBytes(name.replace('.', '/'), '0'))
+        def handler = new CacheTrackingURLStreamHandler(0L)
+        URL sourceUrl = new URL('cnrtrack', 'localhost', -1, '/' + name.replace('.', '/') + '.groovy', handler)
+        parentLoader(tempDir).withCloseable { parent ->
+            def config = configWith([:])
+            def loader = new GroovyClassLoader(parent, config)
+            loader.resourceLoader = { String filename ->
+                filename == name ? sourceUrl : null
+            } as GroovyResourceLoader
+            def unit = new CompilationUnit(config, null, loader)
+            def result = new ClassNodeResolver().resolveName(name, unit)
+            assert result.classNode
+            assert result.getClassNode().name == name
+        }
+        assert handler.lastModifiedCalled
+        assert handler.useCaches == Boolean.FALSE
+    }
+
+    @Test
+    void missingJarEntryKeepsTheExistingClass() {
+        String name = 'cnr.recompile.MissingEntry'
+        writeBytes(tempDir, name.replace('.', '/'), stampedClassBytes(name.replace('.', '/'), '0'))
+        Path jar = tempDir.resolve('other.jar')
+        new java.util.jar.JarOutputStream(Files.newOutputStream(jar)).withCloseable { jos ->
+            jos.putNextEntry(new java.util.jar.JarEntry('unrelated.txt'))
+            jos.write('x'.bytes)
+            jos.closeEntry()
+        }
+        URL sourceUrl = new URL('jar:' + jar.toUri().toURL().toExternalForm() + '!/' + name.replace('.', '/') + '.groovy')
+        parentLoader(tempDir).withCloseable { parent ->
+            def config = configWith([:])
+            def loader = new GroovyClassLoader(parent, config)
+            loader.resourceLoader = { String filename ->
+                filename == name ? sourceUrl : null
+            } as GroovyResourceLoader
+            def unit = new CompilationUnit(config, null, loader)
+            def result = new ClassNodeResolver().resolveName(name, unit)
+            assert result.classNode
+            assert result.getClassNode().name == name
+        }
+        Files.delete(jar)
     }
 
     @Test
@@ -867,4 +938,61 @@ class ClassNodeResolverTest {
             return super.getResource(name)
         }
     }
+
+    /**
+     * Records how {@link ClassNodeResolver} opens a non-{@code file:} source URL
+     * for the freshness check: caching must be off, and the stream must be closed.
+     */
+    static class CacheTrackingURLStreamHandler extends URLStreamHandler {
+        final long lastModified
+        Boolean useCaches
+        boolean lastModifiedCalled
+        boolean inputStreamClosed
+
+        CacheTrackingURLStreamHandler(long lastModified) {
+            this.lastModified = lastModified
+        }
+
+        @Override
+        protected URLConnection openConnection(URL u) {
+            new CacheTrackingURLConnection(u, this)
+        }
+    }
+
+    private static class CacheTrackingURLConnection extends URLConnection {
+        private final CacheTrackingURLStreamHandler handler
+
+        CacheTrackingURLConnection(URL url, CacheTrackingURLStreamHandler handler) {
+            super(url)
+            this.handler = handler
+        }
+
+        @Override
+        void connect() {
+            connected = true
+        }
+
+        @Override
+        void setUseCaches(boolean usecaches) {
+            handler.useCaches = usecaches
+            super.setUseCaches(usecaches)
+        }
+
+        @Override
+        long getLastModified() {
+            handler.lastModifiedCalled = true
+            return handler.lastModified
+        }
+
+        @Override
+        InputStream getInputStream() {
+            return new FilterInputStream(InputStream.nullInputStream()) {
+                @Override
+                void close() throws IOException {
+                    handler.inputStreamClosed = true
+                    super.close()
+                }
+            }
+        }
+    }
 }
diff --git a/src/test/groovy/org/codehaus/groovy/util/URLStreamsTest.groovy b/src/test/groovy/org/codehaus/groovy/util/URLStreamsTest.groovy
new file mode 100644
index 0000000000..fb4db44313
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/util/URLStreamsTest.groovy
@@ -0,0 +1,197 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.codehaus.groovy.util
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.jar.JarEntry
+import java.util.jar.JarOutputStream
+
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Tests for {@link URLStreams}: uncached streams and last-modified lookups
+ * must not pin {@code file:} / {@code jar:} handles.
+ */
+final class URLStreamsTest {
+
+    @TempDir
+    Path tempDir
+
+    @Test
+    void getUncachedLastModifiedDisablesCachesAndClosesTheStream() {
+        def handler = new TrackingHandler(1234L)
+        URL url = urlFor(handler)
+        assert URLStreams.getUncachedLastModified(url) == 1234L
+        assert handler.useCaches == Boolean.FALSE
+        assert handler.lastModifiedCalled
+        assert handler.inputStreamClosed
+    }
+
+    @Test
+    void getUncachedLastModifiedPropagatesFailureToOpenTheStream() {
+        def handler = new TrackingHandler(99L)
+        handler.inputStreamError = new IOException('cannot open')
+        def error = shouldFail(IOException) {
+            URLStreams.getUncachedLastModified(urlFor(handler))
+        }
+        assert error.message == 'cannot open'
+        assert handler.useCaches == Boolean.FALSE
+        assert !handler.lastModifiedCalled
+        assert !handler.inputStreamClosed
+    }
+
+    @Test
+    void getUncachedLastModifiedReturnsTheValueWhenClosingTheStreamFails() {
+        def handler = new TrackingHandler(99L)
+        handler.closeError = new IOException('cannot close')
+        assert URLStreams.getUncachedLastModified(urlFor(handler)) == 99L
+        assert handler.useCaches == Boolean.FALSE
+        assert handler.lastModifiedCalled
+        assert handler.inputStreamClosed
+    }
+
+    @Test
+    void getUncachedLastModifiedClosesTheStreamWhenLastModifiedFails() {
+        def handler = new TrackingHandler(0L)
+        handler.lastModifiedError = new IOException('cannot stat')
+        def error = shouldFail(IOException) {
+            URLStreams.getUncachedLastModified(urlFor(handler))
+        }
+        assert error.message == 'cannot stat'
+        assert handler.inputStreamClosed
+    }
+
+    @Test
+    void getUncachedLastModifiedPropagatesOpenFailureBeforeLastModified() {
+        def handler = new TrackingHandler(0L)
+        handler.lastModifiedError = new IOException('cannot stat')
+        handler.inputStreamError = new IOException('cannot open')
+        def error = shouldFail(IOException) {
+            URLStreams.getUncachedLastModified(urlFor(handler))
+        }
+        assert error.message == 'cannot open'
+        assert !handler.inputStreamClosed
+    }
+
+    @Test
+    void getUncachedLastModifiedReadsAFileUrlWithoutPinningIt() {
+        Path file = tempDir.resolve('a.txt')
+        Files.writeString(file, 'x')
+        long expected = Files.getLastModifiedTime(file).toMillis()
+        assert URLStreams.getUncachedLastModified(file.toUri().toURL()) == expected
+        Files.delete(file)
+    }
+
+    @Test
+    void getUncachedLastModifiedDoesNotPinAJarFile() {
+        Path jar = tempDir.resolve('sources.jar')
+        new JarOutputStream(Files.newOutputStream(jar)).withCloseable { jos ->
+            jos.putNextEntry(new JarEntry('Foo.groovy'))
+            jos.write('class Foo {}'.bytes)
+            jos.closeEntry()
+        }
+        URL url = new URL('jar:' + jar.toUri().toURL().toExternalForm() + '!/Foo.groovy')
+        assert URLStreams.getUncachedLastModified(url) > 0
+        // Windows cannot delete a JAR that JarURLConnection still holds
+        Files.delete(jar)
+    }
+
+    @Test
+    void openUncachedStreamDisablesCachesAndReturnsTheBody() {
+        def handler = new TrackingHandler(0L)
+        URLStreams.openUncachedStream(urlFor(handler)).withCloseable { stream ->
+            assert stream.readAllBytes() == new byte[0]
+        }
+        assert handler.useCaches == Boolean.FALSE
+        assert handler.inputStreamClosed
+    }
+
+    private static URL urlFor(TrackingHandler handler) {
+        new URL('cnrtrack', 'localhost', -1, '/x', handler)
+    }
+
+    private static class TrackingHandler extends URLStreamHandler {
+        final long lastModified
+        Boolean useCaches
+        boolean lastModifiedCalled
+        boolean inputStreamClosed
+        IOException lastModifiedError
+        IOException inputStreamError
+        IOException closeError
+
+        TrackingHandler(long lastModified) {
+            this.lastModified = lastModified
+        }
+
+        @Override
+        protected URLConnection openConnection(URL u) {
+            new TrackingConnection(u, this)
+        }
+    }
+
+    private static class TrackingConnection extends URLConnection {
+        private final TrackingHandler handler
+
+        TrackingConnection(URL url, TrackingHandler handler) {
+            super(url)
+            this.handler = handler
+        }
+
+        @Override
+        void connect() {
+            connected = true
+        }
+
+        @Override
+        void setUseCaches(boolean usecaches) {
+            handler.useCaches = usecaches
+            super.setUseCaches(usecaches)
+        }
+
+        @Override
+        long getLastModified() {
+            handler.lastModifiedCalled = true
+            if (handler.lastModifiedError != null) {
+                throw handler.lastModifiedError
+            }
+            return handler.lastModified
+        }
+
+        @Override
+        InputStream getInputStream() {
+            if (handler.inputStreamError != null) {
+                throw handler.inputStreamError
+            }
+            return new FilterInputStream(InputStream.nullInputStream()) {
+                @Override
+                void close() throws IOException {
+                    handler.inputStreamClosed = true
+                    if (handler.closeError != null) {
+                        throw handler.closeError
+                    }
+                    super.close()
+                }
+            }
+        }
+    }
+}