From 084142b99bfeab80ee9081e280ff017cdb9741e7 Mon Sep 17 00:00:00 2001 From: Andrea Del Bene Date: Tue, 27 Oct 2026 13:03:01 +0100 Subject: [PATCH 1/3] Fix to avoid a separate ClassLoader for proxied classes --- .../bytebuddy/ByteBuddyProxyFactory.java | 33 +++++++++- .../bytebuddy/ByteBuddyProxyFactoryTest.java | 61 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 wicket-ioc/src/test/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactoryTest.java diff --git a/wicket-ioc/src/main/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactory.java b/wicket-ioc/src/main/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactory.java index b372692de1a..162363448c0 100644 --- a/wicket-ioc/src/main/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactory.java +++ b/wicket-ioc/src/main/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactory.java @@ -102,7 +102,7 @@ public T createProxy(final Class type, final IProxyTargetLocator locator) @SuppressWarnings("unchecked") public static Class createOrGetProxyClass(Class type) { - ClassLoader classLoader = resolveClassLoader(); + ClassLoader classLoader = resolveClassLoader(type); return (Class) DYNAMIC_CLASS_CACHE.findOrInsert(classLoader, new TypeCache.SimpleKey(type), @@ -147,6 +147,37 @@ private static ClassLoadingStrategy resolveLoadingStrategy(Class } } + /** + * Resolves the class loader the proxy class for the given type is defined in. It is also the + * class loader the proxy class is cached for. + *

+ * Unless the type is a java.** one (see + * {@link #resolveLoadingStrategy(Class)}), the proxy class is defined by lookup in the class + * loader of the proxied type, no matter which class loader the current thread resolves. The + * cache has to be keyed on that same class loader: if it was keyed on the class loader of the + * current thread, another thread (e.g. a servlet container request thread, whose context class + * loader differs from the one of the thread that initialized the application) would miss the + * cached class and try to define it a second time in the class loader of the type, failing with + * a {@link LinkageError} (duplicate class definition). + * + * @param type + * the type to proxy + * @return the class loader the proxy class is defined in and cached for + */ + private static ClassLoader resolveClassLoader(Class type) + { + if (!type.getName().startsWith("java.")) + { + ClassLoader typeClassLoader = type.getClassLoader(); + if (typeClassLoader != null) + { + return typeClassLoader; + } + } + + return resolveClassLoader(); + } + private static ClassLoader resolveClassLoader() { ClassLoader classLoader = null; diff --git a/wicket-ioc/src/test/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactoryTest.java b/wicket-ioc/src/test/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactoryTest.java new file mode 100644 index 00000000000..9af2c5999f8 --- /dev/null +++ b/wicket-ioc/src/test/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactoryTest.java @@ -0,0 +1,61 @@ +/* + * 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.apache.wicket.proxy.bytebuddy; + +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.net.URL; +import java.net.URLClassLoader; + +import org.apache.wicket.proxy.util.ConcreteObject; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ByteBuddyProxyFactory} + */ +class ByteBuddyProxyFactoryTest +{ + /** + * The proxy class is defined in the class loader of the proxied type, so it must be cached + * for that class loader, whatever class loader the current thread resolves. Otherwise a + * thread with another context class loader (e.g. a servlet container request thread) does + * not find the cached class and tries to define it a second time, which fails with a + * {@link LinkageError} (duplicate class definition). + */ + @Test + void proxyClassIsReusedAcrossContextClassLoaders() throws Exception + { + Thread thread = Thread.currentThread(); + ClassLoader original = thread.getContextClassLoader(); + + try (URLClassLoader other = new URLClassLoader(new URL[0], original)) + { + Class first = ByteBuddyProxyFactory + .createOrGetProxyClass(ConcreteObject.class); + + thread.setContextClassLoader(other); + Class second = ByteBuddyProxyFactory + .createOrGetProxyClass(ConcreteObject.class); + + assertSame(first, second); + } + finally + { + thread.setContextClassLoader(original); + } + } +} From 34a692d5c817650ad0e2df3cafc973699840b5d7 Mon Sep 17 00:00:00 2001 From: Andrea Del Bene Date: Tue, 27 Oct 2026 15:41:03 +0100 Subject: [PATCH 2/3] Refactored startsWith("java.") check expression --- .../bytebuddy/ByteBuddyProxyFactory.java | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/wicket-ioc/src/main/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactory.java b/wicket-ioc/src/main/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactory.java index 162363448c0..a292bc2c31a 100644 --- a/wicket-ioc/src/main/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactory.java +++ b/wicket-ioc/src/main/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactory.java @@ -123,6 +123,19 @@ public static Class createOrGetProxyClass(Class type) DYNAMIC_CLASS_CACHE); } + /** + * {@code java.*} types cannot host a generated proxy: the JVM forbids defining classes in + * those packages, so they are renamed and loaded through a class loader of their own. + * + * @param prefix + * fully qualified name of the type to proxy + * @return {@code true} when the type belongs to a {@code java.*} package + */ + private static boolean isInternalJavaType(String prefix) + { + return prefix.startsWith("java."); + } + /** * The proxy has to be defined in the same runtime package as the type it proxies, or the * package private methods it overrides are not overridden at all. Only a proxy for a @@ -130,7 +143,7 @@ public static Class createOrGetProxyClass(Class type) */ private static ClassLoadingStrategy resolveLoadingStrategy(Class type) { - if (type.getName().startsWith("java.")) + if (isInternalJavaType(type.getName())) { return ClassLoadingStrategy.Default.WRAPPER.allowExistingTypes(); } @@ -166,7 +179,7 @@ private static ClassLoadingStrategy resolveLoadingStrategy(Class */ private static ClassLoader resolveClassLoader(Class type) { - if (!type.getName().startsWith("java.")) + if (!isInternalJavaType(type.getName())) { ClassLoader typeClassLoader = type.getClassLoader(); if (typeClassLoader != null) @@ -235,7 +248,7 @@ protected String name(TypeDescription superClass) { String packageName = prefix.substring(0, lastIdxOfDot); String className = prefix.substring(lastIdxOfDot + 1); String name = packageName + "."; - if (prefix.startsWith("java.")) + if (isInternalJavaType(prefix)) { name = "bytebuddy_generated_wicket_proxy." + name + className; } From f3c46aa4c63dcd665561a3eb255efaf34c6445e1 Mon Sep 17 00:00:00 2001 From: Martijn Dashorst Date: Wed, 28 Oct 2026 10:44:23 +0100 Subject: [PATCH 3/3] Key the proxy class cache on the type's loader only, and test java.* proxies A non-java.* type on the bootstrap class loader cannot get a private lookup, so its proxy fails on that regardless of the class loader it is cached for; the fallback to the application's class loader for it is gone. isInternalJavaType becomes isInJavaPackage, because java.sql and java.net.http are not internal. The rationale on resolveClassLoader(Class) moves to the commit message, and a test proxies java.sql.Timestamp, whose platform class loader is the reason the java.* check in resolveClassLoader(Class) has to stay. Generated-by: Claude Code (Claude Opus 5.5) --- .../bytebuddy/ByteBuddyProxyFactory.java | 42 +++++-------------- .../bytebuddy/ByteBuddyProxyFactoryTest.java | 22 +++++++--- 2 files changed, 27 insertions(+), 37 deletions(-) diff --git a/wicket-ioc/src/main/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactory.java b/wicket-ioc/src/main/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactory.java index a292bc2c31a..45b1bb1d30f 100644 --- a/wicket-ioc/src/main/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactory.java +++ b/wicket-ioc/src/main/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactory.java @@ -124,16 +124,12 @@ public static Class createOrGetProxyClass(Class type) } /** - * {@code java.*} types cannot host a generated proxy: the JVM forbids defining classes in - * those packages, so they are renamed and loaded through a class loader of their own. - * - * @param prefix - * fully qualified name of the type to proxy - * @return {@code true} when the type belongs to a {@code java.*} package + * The JVM does not allow a class to be defined in a java.** package, so the proxy for + * a type in one is renamed into another package. */ - private static boolean isInternalJavaType(String prefix) + private static boolean isInJavaPackage(String typeName) { - return prefix.startsWith("java."); + return typeName.startsWith("java."); } /** @@ -143,7 +139,7 @@ private static boolean isInternalJavaType(String prefix) */ private static ClassLoadingStrategy resolveLoadingStrategy(Class type) { - if (isInternalJavaType(type.getName())) + if (isInJavaPackage(type.getName())) { return ClassLoadingStrategy.Default.WRAPPER.allowExistingTypes(); } @@ -161,34 +157,16 @@ private static ClassLoadingStrategy resolveLoadingStrategy(Class } /** - * Resolves the class loader the proxy class for the given type is defined in. It is also the - * class loader the proxy class is cached for. - *

- * Unless the type is a java.** one (see - * {@link #resolveLoadingStrategy(Class)}), the proxy class is defined by lookup in the class - * loader of the proxied type, no matter which class loader the current thread resolves. The - * cache has to be keyed on that same class loader: if it was keyed on the class loader of the - * current thread, another thread (e.g. a servlet container request thread, whose context class - * loader differs from the one of the thread that initialized the application) would miss the - * cached class and try to define it a second time in the class loader of the type, failing with - * a {@link LinkageError} (duplicate class definition). - * - * @param type - * the type to proxy - * @return the class loader the proxy class is defined in and cached for + * The class loader the proxy for the given type is defined in, and cached for. */ private static ClassLoader resolveClassLoader(Class type) { - if (!isInternalJavaType(type.getName())) + if (isInJavaPackage(type.getName())) { - ClassLoader typeClassLoader = type.getClassLoader(); - if (typeClassLoader != null) - { - return typeClassLoader; - } + return resolveClassLoader(); } - return resolveClassLoader(); + return type.getClassLoader(); } private static ClassLoader resolveClassLoader() @@ -248,7 +226,7 @@ protected String name(TypeDescription superClass) { String packageName = prefix.substring(0, lastIdxOfDot); String className = prefix.substring(lastIdxOfDot + 1); String name = packageName + "."; - if (isInternalJavaType(prefix)) + if (isInJavaPackage(prefix)) { name = "bytebuddy_generated_wicket_proxy." + name + className; } diff --git a/wicket-ioc/src/test/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactoryTest.java b/wicket-ioc/src/test/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactoryTest.java index 9af2c5999f8..333d87b9667 100644 --- a/wicket-ioc/src/test/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactoryTest.java +++ b/wicket-ioc/src/test/java/org/apache/wicket/proxy/bytebuddy/ByteBuddyProxyFactoryTest.java @@ -16,12 +16,15 @@ */ package org.apache.wicket.proxy.bytebuddy; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import java.net.URL; import java.net.URLClassLoader; +import java.sql.Timestamp; import org.apache.wicket.proxy.util.ConcreteObject; +import org.apache.wicket.proxy.util.MockObjectLocator; import org.junit.jupiter.api.Test; /** @@ -30,11 +33,7 @@ class ByteBuddyProxyFactoryTest { /** - * The proxy class is defined in the class loader of the proxied type, so it must be cached - * for that class loader, whatever class loader the current thread resolves. Otherwise a - * thread with another context class loader (e.g. a servlet container request thread) does - * not find the cached class and tries to define it a second time, which fails with a - * {@link LinkageError} (duplicate class definition). + * A thread with another context class loader gets the proxy class that is already defined. */ @Test void proxyClassIsReusedAcrossContextClassLoaders() throws Exception @@ -58,4 +57,17 @@ void proxyClassIsReusedAcrossContextClassLoaders() throws Exception thread.setContextClassLoader(original); } } + + /** + * A type in a java.** package of the platform class loader, which cannot see the + * interfaces the proxy implements. + */ + @Test + void proxiesTypeInJavaPackageOfPlatformClassLoader() + { + Timestamp proxy = new ByteBuddyProxyFactory().createProxy(Timestamp.class, + new MockObjectLocator(new Timestamp(42))); + + assertEquals(42, proxy.getTime()); + } }