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

lukaszlenart pushed a commit to branch WW-5537-classloader-leak-fixes
in repository https://gitbox.apache.org/repos/asf/struts.git

commit b37bc7df4ae2b495b642d596c0bf713386ac7119
Author: Lukasz Lenart <[email protected]>
AuthorDate: Mon Mar 23 10:03:07 2026 +0100

    WW-5537 Rewrite DispatcherCleanupTest for InternalDestroyable discovery
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
 .../struts2/dispatcher/DispatcherCleanupTest.java  | 180 +++++++++++++++++++++
 1 file changed, 180 insertions(+)

diff --git 
a/core/src/test/java/org/apache/struts2/dispatcher/DispatcherCleanupTest.java 
b/core/src/test/java/org/apache/struts2/dispatcher/DispatcherCleanupTest.java
new file mode 100644
index 000000000..9ecedf203
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/dispatcher/DispatcherCleanupTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.ActionContext;
+import org.apache.struts2.StrutsJUnit4InternalTestCase;
+import org.apache.struts2.components.Component;
+import org.apache.struts2.inject.Container;
+import org.apache.struts2.ognl.accessor.CompoundRootAccessor;
+import org.apache.struts2.util.DebugUtils;
+import org.apache.struts2.util.fs.DefaultFileManager;
+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 static java.util.Collections.emptyMap;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * WW-5537: Verifies that Dispatcher.cleanup() properly clears all static state
+ * that could prevent classloader garbage collection during hot redeployment.
+ */
+public class DispatcherCleanupTest extends StrutsJUnit4InternalTestCase {
+
+    @Test
+    public void cleanupDiscoversAllInternalDestroyableBeans() {
+        initDispatcher(emptyMap());
+
+        Container container = 
dispatcher.getConfigurationManager().getConfiguration().getContainer();
+        Set<String> names = 
container.getInstanceNames(InternalDestroyable.class);
+
+        Set<String> expected = new HashSet<>(Arrays.asList(
+                "componentCache", "compoundRootAccessor", "defaultFileManager",
+                "scopeInterceptorCache", "ognlCache", 
"finalizableReferenceQueue",
+                "freemarkerCache", "debugUtilsCache"
+        ));
+        assertThat(names).containsAll(expected);
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void cleanupClearsComponentStandardAttributesMap() throws Exception 
{
+        initDispatcher(emptyMap());
+
+        Field mapField = 
Component.class.getDeclaredField("standardAttributesMap");
+        mapField.setAccessible(true);
+        ConcurrentMap<Class<?>, Collection<String>> map =
+                (ConcurrentMap<Class<?>, Collection<String>>) 
mapField.get(null);
+
+        map.put(String.class, new ArrayList<>());
+        assertThat(map).isNotEmpty();
+
+        dispatcher.cleanup();
+
+        assertThat(map).isEmpty();
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void cleanupClearsCompoundRootAccessorCache() throws Exception {
+        initDispatcher(emptyMap());
+
+        Field field = 
CompoundRootAccessor.class.getDeclaredField("invalidMethods");
+        field.setAccessible(true);
+        Map<Object, Boolean> invalidMethods = (Map<Object, Boolean>) 
field.get(null);
+
+        invalidMethods.put("testKey", Boolean.TRUE);
+        assertThat(invalidMethods).isNotEmpty();
+
+        dispatcher.cleanup();
+
+        assertThat(invalidMethods).isEmpty();
+    }
+
+    @Test
+    public void cleanupClearsDefaultFileManagerFilesMap() throws Exception {
+        initDispatcher(emptyMap());
+
+        Field filesField = DefaultFileManager.class.getDeclaredField("files");
+        filesField.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        Map<String, Object> files = (Map<String, Object>) filesField.get(null);
+
+        files.put("test-key", new Object());
+        assertThat(files).isNotEmpty();
+
+        dispatcher.cleanup();
+
+        assertThat(files).isEmpty();
+    }
+
+    @Test
+    public void cleanupClearsDefaultFileManagerLazyCache() throws Exception {
+        initDispatcher(emptyMap());
+
+        Field lazyCacheField = 
DefaultFileManager.class.getDeclaredField("lazyMonitoredFilesCache");
+        lazyCacheField.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        List<URL> lazyCache = (List<URL>) lazyCacheField.get(null);
+
+        lazyCache.add(new URL("file:///test"));
+        assertThat(lazyCache).isNotEmpty();
+
+        dispatcher.cleanup();
+
+        assertThat(lazyCache).isEmpty();
+    }
+
+    @Test
+    public void cleanupClearsDispatcherListeners() throws Exception {
+        initDispatcher(emptyMap());
+
+        Dispatcher.addDispatcherListener(new DispatcherListener() {
+            @Override
+            public void dispatcherInitialized(Dispatcher du) {}
+            @Override
+            public void dispatcherDestroyed(Dispatcher du) {}
+        });
+
+        dispatcher.cleanup();
+
+        Field listenersField = 
Dispatcher.class.getDeclaredField("dispatcherListeners");
+        listenersField.setAccessible(true);
+        List<?> listeners = (List<?>) listenersField.get(null);
+        assertThat(listeners).isEmpty();
+    }
+
+    @Test
+    public void cleanupClearsThreadLocals() {
+        assertThat(Dispatcher.getInstance()).isNotNull();
+        assertThat(ActionContext.getContext()).isNotNull();
+
+        dispatcher.cleanup();
+
+        assertThat(Dispatcher.getInstance()).isNull();
+        assertThat(ActionContext.getContext()).isNull();
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void cleanupClearsDebugUtilsCache() throws Exception {
+        initDispatcher(emptyMap());
+
+        Field field = DebugUtils.class.getDeclaredField("IS_LOGGED");
+        field.setAccessible(true);
+        Set<String> isLogged = (Set<String>) field.get(null);
+
+        isLogged.add("test-key");
+        assertThat(isLogged).isNotEmpty();
+
+        dispatcher.cleanup();
+
+        assertThat(isLogged).isEmpty();
+    }
+}

Reply via email to