diff --git a/apps/showcase/pom.xml b/apps/showcase/pom.xml index 836aa91fd9..44d51e7bff 100644 --- a/apps/showcase/pom.xml +++ b/apps/showcase/pom.xml @@ -121,6 +121,10 @@ org.apache.logging.log4j log4j-slf4j-impl + + org.apache.logging.log4j + log4j-web + opensymphony diff --git a/core/src/main/java/com/opensymphony/xwork2/inject/util/FinalizableReferenceQueue.java b/core/src/main/java/com/opensymphony/xwork2/inject/util/FinalizableReferenceQueue.java index 2335b2722a..6295b2d227 100644 --- a/core/src/main/java/com/opensymphony/xwork2/inject/util/FinalizableReferenceQueue.java +++ b/core/src/main/java/com/opensymphony/xwork2/inject/util/FinalizableReferenceQueue.java @@ -26,7 +26,7 @@ * * @author Bob Lee (crazybob@google.com) */ -class FinalizableReferenceQueue extends ReferenceQueue { +public class FinalizableReferenceQueue extends ReferenceQueue { private static final Logger logger = Logger.getLogger(FinalizableReferenceQueue.class.getName()); @@ -45,22 +45,49 @@ void deliverBadNews(Throwable t) { logger.log(Level.SEVERE, "Error cleaning up after reference.", t); } + private volatile Thread drainThread; + void start() { Thread thread = new Thread("FinalizableReferenceQueue") { @Override public void run() { - while (true) { + while (!Thread.currentThread().isInterrupted()) { try { cleanUp(remove()); - } catch (InterruptedException e) { /* ignore */ } + } catch (InterruptedException e) { + break; + } } } }; thread.setDaemon(true); thread.start(); + this.drainThread = thread; + } + + /** + * Stops the background drain thread and releases the singleton instance, + * preventing the webapp classloader from being pinned after undeploy. + */ + public static synchronized void stopAndClear() { + if (instance instanceof FinalizableReferenceQueue) { + FinalizableReferenceQueue queue = (FinalizableReferenceQueue) instance; + Thread t = queue.drainThread; + if (t != null) { + t.interrupt(); + try { + t.join(5000); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + t.setContextClassLoader(null); + queue.drainThread = null; + } + } + instance = null; } - static ReferenceQueue instance = createAndStart(); + static volatile ReferenceQueue instance = createAndStart(); static FinalizableReferenceQueue createAndStart() { FinalizableReferenceQueue queue = new FinalizableReferenceQueue(); diff --git a/core/src/main/java/com/opensymphony/xwork2/ognl/accessor/CompoundRootAccessor.java b/core/src/main/java/com/opensymphony/xwork2/ognl/accessor/CompoundRootAccessor.java index be332047bb..fc5ac04e80 100644 --- a/core/src/main/java/com/opensymphony/xwork2/ognl/accessor/CompoundRootAccessor.java +++ b/core/src/main/java/com/opensymphony/xwork2/ognl/accessor/CompoundRootAccessor.java @@ -33,6 +33,7 @@ import org.apache.logging.log4j.Logger; import org.apache.struts2.StrutsConstants; import org.apache.struts2.StrutsException; +import org.apache.struts2.dispatcher.InternalDestroyable; import java.beans.IntrospectionException; import java.beans.PropertyDescriptor; @@ -53,7 +54,7 @@ * @author Rainer Hermanns * @version $Revision$ */ -public class CompoundRootAccessor implements RootAccessor { +public class CompoundRootAccessor implements RootAccessor, InternalDestroyable { /** * Used by OGNl to generate bytecode @@ -74,6 +75,22 @@ public String getSourceSetter(OgnlContext context, Object target, Object index) private final static Logger LOG = LogManager.getLogger(CompoundRootAccessor.class); private final static Class[] EMPTY_CLASS_ARRAY = new Class[0]; private static final Map invalidMethods = new ConcurrentHashMap<>(); + + /** + * Clears the cached invalid methods map to prevent classloader leaks on hot redeploy. + */ + public static void clearCache() { + invalidMethods.clear(); + } + + /** + * @since 6.9.0 + */ + @Override + public void destroy() { + clearCache(); + } + private boolean devMode; private boolean disallowCustomOgnlMap; diff --git a/core/src/main/java/com/opensymphony/xwork2/util/fs/DefaultFileManager.java b/core/src/main/java/com/opensymphony/xwork2/util/fs/DefaultFileManager.java index 5708bd5f22..48317cf68e 100644 --- a/core/src/main/java/com/opensymphony/xwork2/util/fs/DefaultFileManager.java +++ b/core/src/main/java/com/opensymphony/xwork2/util/fs/DefaultFileManager.java @@ -21,6 +21,7 @@ import com.opensymphony.xwork2.FileManager; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.struts2.dispatcher.InternalDestroyable; import java.io.IOException; import java.io.InputStream; @@ -33,7 +34,7 @@ /** * Default implementation of {@link FileManager} */ -public class DefaultFileManager implements FileManager { +public class DefaultFileManager implements FileManager, InternalDestroyable { private static Logger LOG = LogManager.getLogger(DefaultFileManager.class); @@ -43,6 +44,22 @@ public class DefaultFileManager implements FileManager { protected static final Map files = Collections.synchronizedMap(new HashMap()); private static final List lazyMonitoredFilesCache = Collections.synchronizedList(new ArrayList()); + /** + * Clears both the files and lazy monitored files caches to prevent classloader leaks on hot redeploy. + */ + public static void clearCache() { + files.clear(); + lazyMonitoredFilesCache.clear(); + } + + /** + * @since 6.9.0 + */ + @Override + public void destroy() { + clearCache(); + } + protected boolean reloadingConfigs = false; public DefaultFileManager() { diff --git a/core/src/main/java/org/apache/struts2/components/Component.java b/core/src/main/java/org/apache/struts2/components/Component.java index d61bcc0729..7e6bb4d678 100644 --- a/core/src/main/java/org/apache/struts2/components/Component.java +++ b/core/src/main/java/org/apache/struts2/components/Component.java @@ -68,6 +68,13 @@ public class Component { */ protected static ConcurrentMap, Collection> standardAttributesMap = new ConcurrentHashMap<>(); + /** + * Clears the cached standard attributes map to prevent classloader leaks on hot redeploy. + */ + public static void clearStandardAttributesMap() { + standardAttributesMap.clear(); + } + protected boolean devMode = false; protected boolean escapeHtmlBody = false; protected ValueStack stack; diff --git a/core/src/main/java/org/apache/struts2/dispatcher/ComponentCacheDestroyable.java b/core/src/main/java/org/apache/struts2/dispatcher/ComponentCacheDestroyable.java new file mode 100644 index 0000000000..5d02491728 --- /dev/null +++ b/core/src/main/java/org/apache/struts2/dispatcher/ComponentCacheDestroyable.java @@ -0,0 +1,36 @@ +/* + * 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.struts2.dispatcher; + +import org.apache.struts2.components.Component; + +/** + * Clears {@link Component}'s static standard attributes cache to prevent + * classloader leaks on hot redeploy. Wrapper is needed because {@code Component} + * requires constructor arguments that prevent direct container instantiation. + * + * @since 6.9.0 + */ +public class ComponentCacheDestroyable implements InternalDestroyable { + + @Override + public void destroy() { + Component.clearStandardAttributesMap(); + } +} diff --git a/core/src/main/java/org/apache/struts2/dispatcher/ContainerHolder.java b/core/src/main/java/org/apache/struts2/dispatcher/ContainerHolder.java index 2d1c61657e..28d4cc036a 100644 --- a/core/src/main/java/org/apache/struts2/dispatcher/ContainerHolder.java +++ b/core/src/main/java/org/apache/struts2/dispatcher/ContainerHolder.java @@ -21,27 +21,70 @@ import com.opensymphony.xwork2.inject.Container; /** - * Simple class to hold Container instance per thread to minimise number of attempts - * to read configuration and build each time a new configuration. + * Per-thread cache for the Container instance, minimising repeated reads from + * {@link com.opensymphony.xwork2.config.ConfigurationManager}. *

- * As ContainerHolder operates just per thread (which means per request) there is no need - * to check if configuration changed during the same request. If changed between requests, - * first call to store Container in ContainerHolder will be with the new configuration. + * WW-5537: Uses a ThreadLocal for per-request isolation with a volatile generation + * counter for cross-thread invalidation during app undeploy. When + * {@link #invalidateAll()} is called, all threads see the updated generation on their + * next {@link #get()} and return {@code null}, forcing a fresh read from + * ConfigurationManager. This prevents classloader leaks caused by idle pool threads + * retaining stale Container references after hot redeployment. */ class ContainerHolder { - private static final ThreadLocal instance = new ThreadLocal<>(); + private static final ThreadLocal instance = new ThreadLocal<>(); + + /** + * Incremented on each {@link #invalidateAll()} call. Threads compare their cached + * generation against this value to detect staleness. + */ + private static volatile long generation = 0; public static void store(Container newInstance) { - instance.set(newInstance); + instance.set(new CachedContainer(newInstance, generation)); } public static Container get() { - return instance.get(); + CachedContainer cached = instance.get(); + if (cached == null) { + return null; + } + if (cached.generation != generation) { + instance.remove(); + return null; + } + return cached.container; } + /** + * Clears the current thread's cached container reference. + * Used for per-request cleanup. + */ public static void clear() { instance.remove(); } + /** + * Invalidates all threads' cached container references by advancing the generation + * counter. Each thread will detect the stale generation on its next {@link #get()} + * call and clear its own ThreadLocal. Also clears the calling thread immediately. + *

+ * Used during application undeploy ({@link Dispatcher#cleanup()}) to ensure idle + * pool threads do not pin the webapp classloader via retained Container references. + */ + public static void invalidateAll() { + generation++; + instance.remove(); + } + + private static class CachedContainer { + final Container container; + final long generation; + + CachedContainer(Container container, long generation) { + this.container = container; + this.generation = generation; + } + } } diff --git a/core/src/main/java/org/apache/struts2/dispatcher/ContextAwareDestroyable.java b/core/src/main/java/org/apache/struts2/dispatcher/ContextAwareDestroyable.java new file mode 100644 index 0000000000..578620d914 --- /dev/null +++ b/core/src/main/java/org/apache/struts2/dispatcher/ContextAwareDestroyable.java @@ -0,0 +1,53 @@ +/* + * 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.struts2.dispatcher; + +import javax.servlet.ServletContext; + +/** + * Extension of {@link InternalDestroyable} for components that require + * {@link ServletContext} during cleanup (e.g. clearing servlet-scoped caches). + * + *

During {@link Dispatcher#cleanup()}, the discovery loop checks each + * {@code InternalDestroyable} bean: if it implements this subinterface, + * {@link #destroy(ServletContext)} is called instead of {@link #destroy()}.

+ * + * @since 6.9.0 + * @see InternalDestroyable + * @see Dispatcher#cleanup() + */ +public interface ContextAwareDestroyable extends InternalDestroyable { + + /** + * Releases state that requires access to the {@link ServletContext}. + * + * @param servletContext the current servlet context, may be {@code null} + * if the Dispatcher was created without one + */ + void destroy(ServletContext servletContext); + + /** + * Default no-op — {@link Dispatcher} calls + * {@link #destroy(ServletContext)} instead when it recognises this type. + */ + @Override + default void destroy() { + // no-op: context-aware variant is the real entry point + } +} diff --git a/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java b/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java index f551087195..55f0228ba9 100644 --- a/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java +++ b/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java @@ -451,7 +451,37 @@ public void setThreadAllowlist(ThreadAllowlist threadAllowlist) { * Releases all instances bound to this dispatcher instance. */ public void cleanup() { - // clean up ObjectFactory + destroyObjectFactory(); + + // clean up Dispatcher itself for this thread + instance.remove(); + servletContext.setAttribute(StrutsStatics.SERVLET_DISPATCHER, null); + + destroyDispatcherListeners(); + + destroyInterceptors(); + + destroyInternalBeans(); + + // WW-5537: Invalidate all threads' cached Container references to prevent + // classloader leaks from idle pool threads retaining stale references after undeploy. + ContainerHolder.invalidateAll(); + + //cleanup action context + ActionContext.clear(); + + // clean up configuration + configurationManager.destroyConfiguration(); + configurationManager = null; + } + + /** + * Destroys the {@link ObjectFactory} if it implements {@link ObjectFactoryDestroyable}. + * Called at the beginning of {@link #cleanup()}. + * + * @since 6.9.0 + */ + protected void destroyObjectFactory() { if (objectFactory == null) { LOG.warn("Object Factory is null, something is seriously wrong, no clean up will be performed"); } @@ -459,23 +489,36 @@ public void cleanup() { try { ((ObjectFactoryDestroyable) objectFactory).destroy(); } catch (Exception e) { - // catch any exception that may occur during destroy() and log it LOG.error("Exception occurred while destroying ObjectFactory [{}]", objectFactory.toString(), e); } } + } - // clean up Dispatcher itself for this thread - instance.remove(); - servletContext.setAttribute(StrutsStatics.SERVLET_DISPATCHER, null); - - // clean up DispatcherListeners + /** + * Notifies all registered {@link DispatcherListener}s that this dispatcher + * is being destroyed, then clears the listener list. + * + * @since 6.9.0 + */ + protected void destroyDispatcherListeners() { if (!dispatcherListeners.isEmpty()) { for (DispatcherListener l : dispatcherListeners) { l.dispatcherDestroyed(this); } + // WW-5537: Clear the static listener list to release references that may + // pin the webapp classloader after undeploy. Listeners must be re-registered + // if a new Dispatcher is created (e.g. on redeploy). + dispatcherListeners.clear(); } + } - // clean up all interceptors by calling their destroy() method + /** + * Destroys all interceptors registered in the current configuration. + * Called during {@link #cleanup()} before {@link #destroyInternalBeans()}. + * + * @since 6.9.0 + */ + protected void destroyInterceptors() { Set interceptors = new HashSet<>(); Collection packageConfigs = configurationManager.getConfiguration().getPackageConfigs().values(); for (PackageConfig packageConfig : packageConfigs) { @@ -490,16 +533,38 @@ public void cleanup() { for (Interceptor interceptor : interceptors) { interceptor.destroy(); } + } - // Clear container holder when application is unloaded / server shutdown - ContainerHolder.clear(); - - //cleanup action context - ActionContext.clear(); - - // clean up configuration - configurationManager.destroyConfiguration(); - configurationManager = null; + /** + * Discovers and invokes all {@link InternalDestroyable} beans registered + * in the container, clearing static caches and stopping daemon threads + * to prevent classloader leaks during hot redeployment (WW-5537). + * + *

Beans implementing {@link ContextAwareDestroyable} receive the + * {@link javax.servlet.ServletContext} via + * {@link ContextAwareDestroyable#destroy(javax.servlet.ServletContext)}.

+ * + * @since 6.9.0 + */ + protected void destroyInternalBeans() { + if (configurationManager != null && configurationManager.getConfiguration() != null) { + Container container = configurationManager.getConfiguration().getContainer(); + Set destroyableNames = container.getInstanceNames(InternalDestroyable.class); + for (String name : destroyableNames) { + try { + InternalDestroyable destroyable = container.getInstance(InternalDestroyable.class, name); + if (destroyable instanceof ContextAwareDestroyable) { + ((ContextAwareDestroyable) destroyable).destroy(servletContext); + } else { + destroyable.destroy(); + } + } catch (Exception e) { + LOG.warn("Error during internal cleanup [{}]", name, e); + } + } + } else { + LOG.warn("ConfigurationManager is null during cleanup, InternalDestroyable beans will not be invoked"); + } } private void init_FileManager() throws ClassNotFoundException { diff --git a/core/src/main/java/org/apache/struts2/dispatcher/FinalizableReferenceQueueDestroyable.java b/core/src/main/java/org/apache/struts2/dispatcher/FinalizableReferenceQueueDestroyable.java new file mode 100644 index 0000000000..1535d87e26 --- /dev/null +++ b/core/src/main/java/org/apache/struts2/dispatcher/FinalizableReferenceQueueDestroyable.java @@ -0,0 +1,36 @@ +/* + * 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.struts2.dispatcher; + +import com.opensymphony.xwork2.inject.util.FinalizableReferenceQueue; + +/** + * Adapter that exposes {@link FinalizableReferenceQueue#stopAndClear()} as an + * {@link InternalDestroyable} bean, since {@code FinalizableReferenceQueue} + * has a private constructor and cannot be directly registered in the container. + * + * @since 6.9.0 + */ +public class FinalizableReferenceQueueDestroyable implements InternalDestroyable { + + @Override + public void destroy() { + FinalizableReferenceQueue.stopAndClear(); + } +} diff --git a/core/src/main/java/org/apache/struts2/dispatcher/FreemarkerCacheDestroyable.java b/core/src/main/java/org/apache/struts2/dispatcher/FreemarkerCacheDestroyable.java new file mode 100644 index 0000000000..946684e034 --- /dev/null +++ b/core/src/main/java/org/apache/struts2/dispatcher/FreemarkerCacheDestroyable.java @@ -0,0 +1,57 @@ +/* + * 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.struts2.dispatcher; + +import freemarker.ext.beans.BeansWrapper; +import freemarker.template.Configuration; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.struts2.views.freemarker.FreemarkerManager; + +import javax.servlet.ServletContext; + +/** + * WW-5537: Clears FreeMarker's template and class introspection caches + * stored in {@link ServletContext} during application undeploy, preventing + * classloader leaks. + * + * @since 6.9.0 + */ +public class FreemarkerCacheDestroyable implements ContextAwareDestroyable { + + private static final Logger LOG = LogManager.getLogger(FreemarkerCacheDestroyable.class); + + @Override + public void destroy(ServletContext servletContext) { + if (servletContext == null) { + return; + } + Object fmConfig = servletContext.getAttribute(FreemarkerManager.CONFIG_SERVLET_CONTEXT_KEY); + if (fmConfig instanceof Configuration) { + Configuration cfg = (Configuration) fmConfig; + cfg.clearTemplateCache(); + cfg.clearEncodingMap(); + if (cfg.getObjectWrapper() instanceof BeansWrapper) { + ((BeansWrapper) cfg.getObjectWrapper()).clearClassIntrospectionCache(); + } + servletContext.removeAttribute(FreemarkerManager.CONFIG_SERVLET_CONTEXT_KEY); + LOG.debug("FreeMarker configuration cleaned up"); + } + } +} diff --git a/core/src/main/java/org/apache/struts2/dispatcher/InternalDestroyable.java b/core/src/main/java/org/apache/struts2/dispatcher/InternalDestroyable.java new file mode 100644 index 0000000000..085f548791 --- /dev/null +++ b/core/src/main/java/org/apache/struts2/dispatcher/InternalDestroyable.java @@ -0,0 +1,50 @@ +/* + * 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.struts2.dispatcher; + +/** + * Internal framework interface for components that hold static state + * (caches, daemon threads, etc.) requiring cleanup during application + * undeploy to prevent classloader leaks. + * + *

Implementations are registered as named beans in {@code struts-beans.xml} + * (or plugin descriptors) with type {@code InternalDestroyable}. During + * {@link Dispatcher#cleanup()}, all registered implementations are discovered + * via {@code Container.getInstanceNames(InternalDestroyable.class)} and + * invoked automatically.

+ * + *

The order in which implementations are invoked is not guaranteed. + * Implementations must not depend on other {@code InternalDestroyable} + * beans having been (or not yet been) destroyed. Ordering can be + * influenced via the {@code order} attribute in bean registration.

+ * + *

This is not part of the public user API. For user/plugin lifecycle + * callbacks, use {@link DispatcherListener} instead.

+ * + * @since 6.9.0 + * @see Dispatcher#cleanup() + */ +public interface InternalDestroyable { + + /** + * Releases static state held by this component. Called once during + * {@link Dispatcher#cleanup()}. + */ + void destroy(); +} diff --git a/core/src/main/java/org/apache/struts2/dispatcher/OgnlCacheDestroyable.java b/core/src/main/java/org/apache/struts2/dispatcher/OgnlCacheDestroyable.java new file mode 100644 index 0000000000..9bf9915b91 --- /dev/null +++ b/core/src/main/java/org/apache/struts2/dispatcher/OgnlCacheDestroyable.java @@ -0,0 +1,38 @@ +/* + * 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.struts2.dispatcher; + +import com.opensymphony.xwork2.ognl.OgnlUtil; + +import java.beans.Introspector; + +/** + * Clears OGNL runtime caches and JDK introspection caches that hold + * {@code Class} references, preventing classloader leaks on hot redeploy. + * + * @since 6.9.0 + */ +public class OgnlCacheDestroyable implements InternalDestroyable { + + @Override + public void destroy() { + OgnlUtil.clearRuntimeCache(); + Introspector.flushCaches(); + } +} diff --git a/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java b/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java index ab4323526e..961412a5d1 100644 --- a/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java +++ b/core/src/main/java/org/apache/struts2/dispatcher/PrepareOperations.java @@ -77,6 +77,7 @@ public void cleanupRequest(final HttpServletRequest request) { } finally { ActionContext.clear(); Dispatcher.clearInstance(); + ContainerHolder.clear(); devModeOverride.remove(); } }); diff --git a/core/src/main/java/org/apache/struts2/dispatcher/ScopeInterceptorCacheDestroyable.java b/core/src/main/java/org/apache/struts2/dispatcher/ScopeInterceptorCacheDestroyable.java new file mode 100644 index 0000000000..7a7f54406e --- /dev/null +++ b/core/src/main/java/org/apache/struts2/dispatcher/ScopeInterceptorCacheDestroyable.java @@ -0,0 +1,37 @@ +/* + * 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.struts2.dispatcher; + +import org.apache.struts2.interceptor.ScopeInterceptor; + +/** + * Clears {@link ScopeInterceptor}'s static locks map to prevent classloader + * leaks on hot redeploy. Separated from the interceptor itself because the + * locks map is static and must be cleared regardless of whether the interceptor + * is configured in any package. + * + * @since 6.9.0 + */ +public class ScopeInterceptorCacheDestroyable implements InternalDestroyable { + + @Override + public void destroy() { + ScopeInterceptor.clearLocks(); + } +} diff --git a/core/src/main/java/org/apache/struts2/interceptor/ScopeInterceptor.java b/core/src/main/java/org/apache/struts2/interceptor/ScopeInterceptor.java index 765fa40ed0..77789c1d26 100644 --- a/core/src/main/java/org/apache/struts2/interceptor/ScopeInterceptor.java +++ b/core/src/main/java/org/apache/struts2/interceptor/ScopeInterceptor.java @@ -232,7 +232,21 @@ private static Object nullConvert(Object o) { return o; } - private static Map locks = new IdentityHashMap<>(); + private static final Map locks = new IdentityHashMap<>(); + + /** + * Clears the locks map to prevent classloader leaks on hot redeploy. + */ + public static void clearLocks() { + synchronized (locks) { + locks.clear(); + } + } + + @Override + public void destroy() { + clearLocks(); + } static void lock(Object o, ActionInvocation invocation) throws Exception { synchronized (o) { diff --git a/core/src/main/resources/struts-beans.xml b/core/src/main/resources/struts-beans.xml index bb0ede4809..878692bdf6 100644 --- a/core/src/main/resources/struts-beans.xml +++ b/core/src/main/resources/struts-beans.xml @@ -274,4 +274,20 @@ + + + + + + + + + diff --git a/core/src/test/java/com/opensymphony/xwork2/inject/util/FinalizableReferenceQueueTest.java b/core/src/test/java/com/opensymphony/xwork2/inject/util/FinalizableReferenceQueueTest.java new file mode 100644 index 0000000000..2d09a96559 --- /dev/null +++ b/core/src/test/java/com/opensymphony/xwork2/inject/util/FinalizableReferenceQueueTest.java @@ -0,0 +1,38 @@ +/* + * 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 com.opensymphony.xwork2.inject.util; + +import org.junit.Test; + +import java.lang.ref.ReferenceQueue; + +import static org.junit.Assert.assertNull; + +public class FinalizableReferenceQueueTest { + + @Test + public void stopAndClearIsIdempotent() { + // Should not throw even when called multiple times + FinalizableReferenceQueue.stopAndClear(); + FinalizableReferenceQueue.stopAndClear(); + + ReferenceQueue instance = FinalizableReferenceQueue.getInstance(); + assertNull("FinalizableReferenceQueue instance should be null after stopAndClear", instance); + } +} diff --git a/core/src/test/java/org/apache/struts2/dispatcher/DispatcherCleanupLeakTest.java b/core/src/test/java/org/apache/struts2/dispatcher/DispatcherCleanupLeakTest.java new file mode 100644 index 0000000000..94ce919b3c --- /dev/null +++ b/core/src/test/java/org/apache/struts2/dispatcher/DispatcherCleanupLeakTest.java @@ -0,0 +1,227 @@ +/* + * 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.struts2.dispatcher; + +import com.opensymphony.xwork2.inject.Container; +import com.opensymphony.xwork2.ognl.accessor.CompoundRootAccessor; +import com.opensymphony.xwork2.util.fs.DefaultFileManager; +import org.apache.struts2.StrutsJUnit4InternalTestCase; +import org.apache.struts2.components.Component; +import org.apache.struts2.interceptor.ScopeInterceptor; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import static java.util.Collections.emptyMap; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * WW-5537: Verifies that Dispatcher.cleanup() properly clears all static state + * that could prevent classloader garbage collection during hot redeployment. + */ +public class DispatcherCleanupLeakTest extends StrutsJUnit4InternalTestCase { + + @Test + public void cleanupDiscoversAllInternalDestroyableBeans() { + initDispatcher(emptyMap()); + + Container container = dispatcher.getConfigurationManager().getConfiguration().getContainer(); + Set names = container.getInstanceNames(InternalDestroyable.class); + + Set expected = new HashSet<>(Arrays.asList( + "componentCache", "compoundRootAccessor", "defaultFileManager", + "scopeInterceptorCache", "ognlCache", "finalizableReferenceQueue", + "freemarkerCache" + )); + assertTrue("All core InternalDestroyable beans should be registered, missing: " + + missing(expected, names), + names.containsAll(expected)); + } + + @Test + public void cleanupContinuesWhenDestroyableThrows() { + initDispatcher(emptyMap()); + + // Populate a cache to verify cleanup still runs after a failure + Field mapField; + try { + mapField = Component.class.getDeclaredField("standardAttributesMap"); + mapField.setAccessible(true); + @SuppressWarnings("unchecked") + ConcurrentMap, Collection> map = + (ConcurrentMap, Collection>) mapField.get(null); + map.put(String.class, new ArrayList<>()); + assertFalse("Precondition: standardAttributesMap should not be empty", map.isEmpty()); + } catch (Exception e) { + throw new RuntimeException(e); + } + + // Register a destroyable that throws before other cleanup runs + final AtomicBoolean secondCalled = new AtomicBoolean(false); + InternalDestroyable failing = () -> { throw new RuntimeException("test failure"); }; + InternalDestroyable tracking = () -> secondCalled.set(true); + + // Call cleanup — the loop should catch the exception and continue + Container container = dispatcher.getConfigurationManager().getConfiguration().getContainer(); + Set names = container.getInstanceNames(InternalDestroyable.class); + + // Simulate the loop with our test destroyables injected + List destroyables = new ArrayList<>(); + destroyables.add(failing); + for (String name : names) { + destroyables.add(container.getInstance(InternalDestroyable.class, name)); + } + destroyables.add(tracking); + + for (InternalDestroyable d : destroyables) { + try { + d.destroy(); + } catch (Exception e) { + // mirrors Dispatcher.cleanup() error handling + } + } + + assertTrue("Destroyable after the failing one should still be called", secondCalled.get()); + } + + @Test + @SuppressWarnings("unchecked") + public void cleanupClearsComponentStandardAttributesMap() throws Exception { + initDispatcher(emptyMap()); + + Field mapField = Component.class.getDeclaredField("standardAttributesMap"); + mapField.setAccessible(true); + ConcurrentMap, Collection> map = + (ConcurrentMap, Collection>) mapField.get(null); + + map.put(String.class, new ArrayList<>()); + assertFalse("Precondition: standardAttributesMap should not be empty", map.isEmpty()); + + dispatcher.cleanup(); + + assertTrue("standardAttributesMap should be empty after cleanup", map.isEmpty()); + } + + @Test + @SuppressWarnings("unchecked") + public void cleanupClearsCompoundRootAccessorCache() throws Exception { + initDispatcher(emptyMap()); + + Field field = CompoundRootAccessor.class.getDeclaredField("invalidMethods"); + field.setAccessible(true); + Map invalidMethods = (Map) field.get(null); + + // Seed with a dummy entry to ensure cleanup actually clears it + invalidMethods.put("testKey", Boolean.TRUE); + assertFalse("Precondition: invalidMethods should not be empty", invalidMethods.isEmpty()); + + dispatcher.cleanup(); + + assertTrue("invalidMethods should be empty after cleanup", invalidMethods.isEmpty()); + } + + @Test + public void cleanupClearsDefaultFileManagerFilesMap() throws Exception { + initDispatcher(emptyMap()); + + Field filesField = DefaultFileManager.class.getDeclaredField("files"); + filesField.setAccessible(true); + @SuppressWarnings("unchecked") + Map files = (Map) filesField.get(null); + + files.put("test-key", new Object()); + assertFalse("Precondition: files should not be empty", files.isEmpty()); + + dispatcher.cleanup(); + + assertTrue("DefaultFileManager.files should be empty after cleanup", files.isEmpty()); + } + + @Test + public void cleanupClearsDefaultFileManagerLazyCache() throws Exception { + initDispatcher(emptyMap()); + + Field lazyCacheField = DefaultFileManager.class.getDeclaredField("lazyMonitoredFilesCache"); + lazyCacheField.setAccessible(true); + @SuppressWarnings("unchecked") + List lazyCache = (List) lazyCacheField.get(null); + + lazyCache.add(new URL("file:///test")); + assertFalse("Precondition: lazyMonitoredFilesCache should not be empty", lazyCache.isEmpty()); + + dispatcher.cleanup(); + + assertTrue("DefaultFileManager.lazyMonitoredFilesCache should be empty after cleanup", + lazyCache.isEmpty()); + } + + @Test + @SuppressWarnings("unchecked") + public void cleanupClearsScopeInterceptorLocks() throws Exception { + initDispatcher(emptyMap()); + + Field locksField = ScopeInterceptor.class.getDeclaredField("locks"); + locksField.setAccessible(true); + Map locks = (Map) locksField.get(null); + + locks.put(new Object(), new Object()); + assertFalse("Precondition: locks should not be empty", locks.isEmpty()); + + dispatcher.cleanup(); + + assertTrue("ScopeInterceptor.locks should be empty after cleanup", locks.isEmpty()); + } + + @Test + public void cleanupClearsDispatcherListeners() throws Exception { + initDispatcher(emptyMap()); + + DispatcherListener listener = new DispatcherListener() { + @Override + public void dispatcherInitialized(Dispatcher du) {} + @Override + public void dispatcherDestroyed(Dispatcher du) {} + }; + Dispatcher.addDispatcherListener(listener); + + dispatcher.cleanup(); + + Field listenersField = Dispatcher.class.getDeclaredField("dispatcherListeners"); + listenersField.setAccessible(true); + List listeners = (List) listenersField.get(null); + assertTrue("dispatcherListeners should be empty after cleanup", listeners.isEmpty()); + } + + private Set missing(Set expected, Set actual) { + Set diff = new HashSet<>(expected); + diff.removeAll(actual); + return diff; + } +} diff --git a/plugins/json/src/main/java/org/apache/struts2/json/DefaultJSONWriter.java b/plugins/json/src/main/java/org/apache/struts2/json/DefaultJSONWriter.java index 16d13df3d5..1ec8d017fd 100644 --- a/plugins/json/src/main/java/org/apache/struts2/json/DefaultJSONWriter.java +++ b/plugins/json/src/main/java/org/apache/struts2/json/DefaultJSONWriter.java @@ -74,6 +74,14 @@ public class DefaultJSONWriter implements JSONWriter { private static final ConcurrentMap, BeanInfo> BEAN_INFO_CACHE_IGNORE_HIERARCHY = new ConcurrentHashMap<>(); private static final ConcurrentMap, BeanInfo> BEAN_INFO_CACHE = new ConcurrentHashMap<>(); + /** + * Clears both BeanInfo caches to prevent classloader leaks on hot redeploy. + */ + public static void clearBeanInfoCaches() { + BEAN_INFO_CACHE_IGNORE_HIERARCHY.clear(); + BEAN_INFO_CACHE.clear(); + } + private final StringBuilder buf = new StringBuilder(); private Stack stack = new Stack<>(); private boolean ignoreHierarchy = true; diff --git a/plugins/json/src/main/java/org/apache/struts2/json/JSONCacheDestroyable.java b/plugins/json/src/main/java/org/apache/struts2/json/JSONCacheDestroyable.java new file mode 100644 index 0000000000..102bd59c85 --- /dev/null +++ b/plugins/json/src/main/java/org/apache/struts2/json/JSONCacheDestroyable.java @@ -0,0 +1,35 @@ +/* + * 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.struts2.json; + +import org.apache.struts2.dispatcher.InternalDestroyable; + +/** + * WW-5537: Clears JSON plugin's static BeanInfo caches when the Dispatcher is + * destroyed, preventing classloader leaks during hot redeployment. + * + * @since 6.9.0 + */ +public class JSONCacheDestroyable implements InternalDestroyable { + + @Override + public void destroy() { + DefaultJSONWriter.clearBeanInfoCaches(); + } +} diff --git a/plugins/json/src/main/resources/struts-plugin.xml b/plugins/json/src/main/resources/struts-plugin.xml index 1291246a71..1f73b92897 100644 --- a/plugins/json/src/main/resources/struts-plugin.xml +++ b/plugins/json/src/main/resources/struts-plugin.xml @@ -29,6 +29,8 @@ + diff --git a/pom.xml b/pom.xml index 674f1b3137..f0aac7434e 100644 --- a/pom.xml +++ b/pom.xml @@ -1034,6 +1034,11 @@ log4j-slf4j-impl ${log4j2.version} + + org.apache.logging.log4j + log4j-web + ${log4j2.version} + org.apache.commons commons-compress