This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch feat/WW-5593-convention-plugin-noclass-exception in repository https://gitbox.apache.org/repos/asf/struts.git
commit 0cb91deb4da55f41095fb34926a83b9e8dd8accd Author: Lukasz Lenart <[email protected]> AuthorDate: Tue Dec 2 18:49:26 2025 +0100 fix(convention): WW-5593 handle NoClassDefFoundError in action class scanning The PackageBasedActionConfigBuilder now catches NoClassDefFoundError in addition to ClassNotFoundException when scanning for action classes. This prevents application startup failures when classes have missing optional dependencies (e.g., test classes depending on JUnit). Changes: - Add NoClassDefFoundError to catch block in getActionClassTest() - Improve error message to suggest missing dependencies - Add unit tests for both exception types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --- .../PackageBasedActionConfigBuilder.java | 5 +- .../PackageBasedActionConfigBuilderTest.java | 182 ++++++++-- ...-WW-5593-convention-plugin-noclass-exception.md | 385 +++++++++++++++++++++ 3 files changed, 538 insertions(+), 34 deletions(-) diff --git a/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java b/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java index 45eb3f8b8..f40f3e94c 100644 --- a/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java +++ b/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java @@ -661,8 +661,9 @@ public class PackageBasedActionConfigBuilder implements ActionConfigBuilder { try { return inPackage && (nameMatches || (checkImplementsAction && org.apache.struts2.action.Action.class.isAssignableFrom(classInfo.get()))); - } catch (ClassNotFoundException ex) { - LOG.error("Unable to load class [{}]", classInfo.getName(), ex); + } catch (ClassNotFoundException | NoClassDefFoundError ex) { + LOG.error("Unable to load class [{}]. Perhaps it exists but certain dependencies are not available?", + classInfo.getName(), ex); return false; } } diff --git a/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java index dcb0b4352..edffbdebc 100644 --- a/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java +++ b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java @@ -19,6 +19,9 @@ package org.apache.struts2.convention; import org.apache.struts2.result.ActionChainResult; +import org.apache.struts2.util.finder.ClassFinder; +import org.apache.struts2.util.finder.Test; + import jakarta.servlet.ServletContext; import junit.framework.TestCase; import org.apache.commons.lang3.StringUtils; @@ -127,8 +130,122 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { public void setUp() throws Exception { super.setUp(); ActionContext.of() - .withContainer(new DummyContainer()) - .bind(); + .withContainer(new DummyContainer()) + .bind(); + } + + /** + * Tests that NoClassDefFoundError is properly caught and handled + * when scanning action classes with missing dependencies. + * <p> + * This is a regression test for WW-5593: Convention plugin fails with + * NoClassDefFoundError when classes have missing optional dependencies. + * + * @see <a href="https://issues.apache.org/jira/browse/WW-5593">WW-5593</a> + */ + public void testNoClassDefFoundErrorHandling() throws Exception { + // Setup minimal configuration + ResultTypeConfig defaultResult = new ResultTypeConfig.Builder("dispatcher", + ServletDispatcherResult.class.getName()).defaultResultParam("location").build(); + PackageConfig strutsDefault = makePackageConfig("struts-default", null, null, "dispatcher", + new ResultTypeConfig[]{defaultResult}, null, null, null, true); + + final DummyContainer mockContainer = new DummyContainer(); + Configuration configuration = new DefaultConfiguration() { + @Override + public Container getContainer() { + return mockContainer; + } + }; + configuration.addPackageConfig("struts-default", strutsDefault); + + ActionNameBuilder actionNameBuilder = new SEOActionNameBuilder("true", "-"); + ObjectFactory of = new ObjectFactory(); + of.setContainer(mockContainer); + + mockContainer.setActionNameBuilder(actionNameBuilder); + mockContainer.setConventionsService(new ConventionsServiceImpl("")); + + // Create the builder with a package that will trigger NoClassDefFoundError simulation + PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder( + configuration, mockContainer, of, "false", "struts-default", "false"); + builder.setActionPackages("org.apache.struts2.convention.actions.noclass"); + builder.setActionSuffix("Action"); + + DefaultFileManagerFactory fileManagerFactory = new DefaultFileManagerFactory(); + fileManagerFactory.setContainer(ActionContext.getContext().getContainer()); + fileManagerFactory.setFileManager(new DefaultFileManager()); + builder.setFileManagerFactory(fileManagerFactory); + builder.setProviderAllowlist(new ProviderAllowlist()); + + // The getActionClassTest() method returns a Test that should catch NoClassDefFoundError + Test<ClassFinder.ClassInfo> actionClassTest = builder.getActionClassTest(); + + // Create a mock ClassInfo that throws NoClassDefFoundError when get() is called + // Note: Class name must NOT end with "Action" suffix to ensure classInfo.get() is actually called + // (otherwise nameMatches=true and the condition short-circuits without calling get()) + ClassFinder.ClassInfo mockClassInfo = EasyMock.createMock(ClassFinder.ClassInfo.class); + EasyMock.expect(mockClassInfo.getName()).andReturn("org.apache.struts2.convention.actions.noclass.MissingDependency").anyTimes(); + EasyMock.expect(mockClassInfo.get()).andThrow(new NoClassDefFoundError("junit/framework/TestCase")); + EasyMock.replay(mockClassInfo); + + // This should return false (class excluded) instead of throwing NoClassDefFoundError + boolean result = actionClassTest.test(mockClassInfo); + assertFalse("NoClassDefFoundError should be caught and class should be excluded", result); + } + + /** + * Tests that ClassNotFoundException is still properly handled. + * This is a companion test to testNoClassDefFoundErrorHandling() to ensure + * the existing ClassNotFoundException handling still works. + */ + public void testClassNotFoundExceptionHandling() throws Exception { + // Setup minimal configuration + ResultTypeConfig defaultResult = new ResultTypeConfig.Builder("dispatcher", + ServletDispatcherResult.class.getName()).defaultResultParam("location").build(); + PackageConfig strutsDefault = makePackageConfig("struts-default", null, null, "dispatcher", + new ResultTypeConfig[]{defaultResult}, null, null, null, true); + + final DummyContainer mockContainer = new DummyContainer(); + Configuration configuration = new DefaultConfiguration() { + @Override + public Container getContainer() { + return mockContainer; + } + }; + configuration.addPackageConfig("struts-default", strutsDefault); + + ActionNameBuilder actionNameBuilder = new SEOActionNameBuilder("true", "-"); + ObjectFactory of = new ObjectFactory(); + of.setContainer(mockContainer); + + mockContainer.setActionNameBuilder(actionNameBuilder); + mockContainer.setConventionsService(new ConventionsServiceImpl("")); + + PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder( + configuration, mockContainer, of, "false", "struts-default", "false"); + builder.setActionPackages("org.apache.struts2.convention.actions.noclass"); + builder.setActionSuffix("Action"); + + DefaultFileManagerFactory fileManagerFactory = new DefaultFileManagerFactory(); + fileManagerFactory.setContainer(ActionContext.getContext().getContainer()); + fileManagerFactory.setFileManager(new DefaultFileManager()); + builder.setFileManagerFactory(fileManagerFactory); + builder.setProviderAllowlist(new ProviderAllowlist()); + + Test<ClassFinder.ClassInfo> actionClassTest = builder.getActionClassTest(); + + // Create a mock ClassInfo that throws ClassNotFoundException when get() is called + // Note: Class name must NOT end with "Action" suffix to ensure classInfo.get() is actually called + // (otherwise nameMatches=true and the condition short-circuits without calling get()) + ClassFinder.ClassInfo mockClassInfo = EasyMock.createMock(ClassFinder.ClassInfo.class); + EasyMock.expect(mockClassInfo.getName()).andReturn("org.apache.struts2.convention.actions.noclass.MissingClass").anyTimes(); + EasyMock.expect(mockClassInfo.get()).andThrow(new ClassNotFoundException("org.example.MissingClass")); + EasyMock.replay(mockClassInfo); + + // This should return false (class excluded) instead of throwing ClassNotFoundException + boolean result = actionClassTest.test(mockClassInfo); + assertFalse("ClassNotFoundException should be caught and class should be excluded", result); } public void testActionPackages() throws MalformedURLException { @@ -158,6 +275,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { private void run(String actionPackages, String packageLocators, String excludePackages) throws MalformedURLException { run(actionPackages, packageLocators, excludePackages, ""); } + private void run(String actionPackages, String packageLocators, String excludePackages, String enableSmiInheritance) throws MalformedURLException { //setup interceptors List<InterceptorConfig> defaultInterceptors = new ArrayList<>(); @@ -198,7 +316,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { PackageConfig classLevelParentPkg = makePackageConfig("class-level", null, null, null); PackageConfig rootPkg = makePackageConfig("org.apache.struts2.convention.actions#struts-default#", - "", strutsDefault, null); + "", strutsDefault, null); PackageConfig paramsPkg = makePackageConfig("org.apache.struts2.convention.actions.params#struts-default#/params", "/params", strutsDefault, null); PackageConfig defaultInterceptorPkg = makePackageConfig("org.apache.struts2.convention.actions.defaultinterceptor#struts-default#/defaultinterceptor", @@ -206,17 +324,17 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { PackageConfig exceptionPkg = makePackageConfig("org.apache.struts2.convention.actions.exception#struts-default#/exception", "/exception", strutsDefault, null); PackageConfig actionPkg = makePackageConfig("org.apache.struts2.convention.actions.action#struts-default#/action", - "/action", strutsDefault, null); + "/action", strutsDefault, null); PackageConfig idxPkg = makePackageConfig("org.apache.struts2.convention.actions.idx#struts-default#/idx", - "/idx", strutsDefault, null); + "/idx", strutsDefault, null); PackageConfig idx2Pkg = makePackageConfig("org.apache.struts2.convention.actions.idx.idx2#struts-default#/idx/idx2", - "/idx/idx2", strutsDefault, null); + "/idx/idx2", strutsDefault, null); PackageConfig interceptorRefsPkg = makePackageConfig("org.apache.struts2.convention.actions.interceptor#struts-default#/interceptor", "/interceptor", strutsDefault, null); PackageConfig packageLevelPkg = makePackageConfig("org.apache.struts2.convention.actions.parentpackage#package-level#/parentpackage", - "/parentpackage", packageLevelParentPkg, null); + "/parentpackage", packageLevelParentPkg, null); PackageConfig packageLevelSubPkg = makePackageConfig("org.apache.struts2.convention.actions.parentpackage.sub#package-level#/parentpackage/sub", - "/parentpackage/sub", packageLevelParentPkg, null); + "/parentpackage/sub", packageLevelParentPkg, null); // Unexpected method call build(class org.apache.struts2.convention.actions.allowedmethods.PackageLevelAllowedMethodsAction, null, "package-level-allowed-methods", PackageConfig: [org.apache.struts2.convention.actions.allowedmethods#struts-default#/allowedmethods] for namespace [/allowedmethods] with parents [[PackageConfig: [struts-default] for namespace [] with parents [[]]]]): PackageConfig packageLevelAllowedMethodsPkg = makePackageConfig("org.apache.struts2.convention.actions.allowedmethods#struts-default#/allowedmethods", @@ -228,17 +346,17 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { "/allowedmethods", strutsDefault, null); PackageConfig differentPkg = makePackageConfig("org.apache.struts2.convention.actions.parentpackage#class-level#/parentpackage", - "/parentpackage", classLevelParentPkg, null); + "/parentpackage", classLevelParentPkg, null); PackageConfig differentSubPkg = makePackageConfig("org.apache.struts2.convention.actions.parentpackage.sub#class-level#/parentpackage/sub", - "/parentpackage/sub", classLevelParentPkg, null); + "/parentpackage/sub", classLevelParentPkg, null); PackageConfig pkgLevelNamespacePkg = makePackageConfig("org.apache.struts2.convention.actions.namespace#struts-default#/package-level", - "/package-level", strutsDefault, null); + "/package-level", strutsDefault, null); PackageConfig classLevelNamespacePkg = makePackageConfig("org.apache.struts2.convention.actions.namespace#struts-default#/class-level", - "/class-level", strutsDefault, null); + "/class-level", strutsDefault, null); PackageConfig actionLevelNamespacePkg = makePackageConfig("org.apache.struts2.convention.actions.namespace#struts-default#/action-level", - "/action-level", strutsDefault, null); + "/action-level", strutsDefault, null); PackageConfig defaultNamespacePkg = makePackageConfig("org.apache.struts2.convention.actions.namespace2#struts-default#/namespace2", - "/namespace2", strutsDefault, null); + "/namespace2", strutsDefault, null); PackageConfig namespaces1Pkg = makePackageConfig("org.apache.struts2.convention.actions.namespace3#struts-default#/namespaces1", "/namespaces1", strutsDefault, null); PackageConfig namespaces2Pkg = makePackageConfig("org.apache.struts2.convention.actions.namespace3#struts-default#/namespaces2", @@ -248,19 +366,19 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { PackageConfig namespaces4Pkg = makePackageConfig("org.apache.struts2.convention.actions.namespace4#struts-default#/namespaces4", "/namespaces4", strutsDefault, null); PackageConfig resultPkg = makePackageConfig("org.apache.struts2.convention.actions.result#struts-default#/result", - "/result", strutsDefault, null); + "/result", strutsDefault, null); PackageConfig globalResultPkg = makePackageConfig("org.apache.struts2.convention.actions.result#class-level#/result", "/result", classLevelParentPkg, null); PackageConfig resultPathPkg = makePackageConfig("org.apache.struts2.convention.actions.resultpath#struts-default#/resultpath", - "/resultpath", strutsDefault, null); + "/resultpath", strutsDefault, null); PackageConfig skipPkg = makePackageConfig("org.apache.struts2.convention.actions.skip#struts-default#/skip", - "/skip", strutsDefault, null); + "/skip", strutsDefault, null); PackageConfig chainPkg = makePackageConfig("org.apache.struts2.convention.actions.chain#struts-default#/chain", - "/chain", strutsDefault, null); + "/chain", strutsDefault, null); PackageConfig transPkg = makePackageConfig("org.apache.struts2.convention.actions.transactions#struts-default#/transactions", - "/transactions", strutsDefault, null); + "/transactions", strutsDefault, null); PackageConfig excludePkg = makePackageConfig("org.apache.struts2.convention.actions.exclude#struts-default#/exclude", - "/exclude", strutsDefault, null); + "/exclude", strutsDefault, null); ResultMapBuilder resultMapBuilder = createStrictMock(ResultMapBuilder.class); checkOrder(resultMapBuilder, false); @@ -409,7 +527,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { mockContainer.setResultMapBuilder(resultMapBuilder); mockContainer.setConventionsService(new ConventionsServiceImpl("")); - PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder(configuration, mockContainer , of, "false", "struts-default", enableSmiInheritance); + PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder(configuration, mockContainer, of, "false", "struts-default", enableSmiInheritance); builder.setFileProtocols("jar"); if (actionPackages != null) { builder.setActionPackages(actionPackages); @@ -706,7 +824,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { verifyActionConfig(pkgConfig, "default-result-path", DefaultResultPathAction.class, "execute", pkgConfig.getName()); verifyActionConfig(pkgConfig, "skip", Skip.class, "execute", pkgConfig.getName()); verifyActionConfig(pkgConfig, "idx", org.apache.struts2.convention.actions.idx.Index.class, "execute", - "org.apache.struts2.convention.actions.idx#struts-default#/idx"); + "org.apache.struts2.convention.actions.idx#struts-default#/idx"); /* org.apache.struts2.convention.actions.transactions */ pkgConfig = configuration.getPackageConfig("org.apache.struts2.convention.actions.transactions#struts-default#/transactions"); @@ -741,7 +859,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { } private void verifyActionConfig(PackageConfig pkgConfig, String actionName, Class<?> actionClass, - String methodName, String packageName) { + String methodName, String packageName) { ActionConfig ac = pkgConfig.getAllActionConfigs().get(actionName); assertNotNull(ac); assertEquals(actionClass.getName(), ac.getClassName()); @@ -756,7 +874,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { } private void verifyMissingActionConfig(PackageConfig pkgConfig, String actionName, Class<?> actionClass, - String methodName, String packageName) { + String methodName, String packageName) { ActionConfig ac = pkgConfig.getAllActionConfigs().get(actionName); assertNull(ac); } @@ -769,10 +887,10 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { assertEquals(packageName, ac.getPackageName()); } - private void checkSmiValue(PackageConfig pkgConfig, PackageConfig parentConfig, boolean isSmiInheritanceEnabled) { + private void checkSmiValue(PackageConfig pkgConfig, PackageConfig parentConfig, boolean isSmiInheritanceEnabled) { if (isSmiInheritanceEnabled) { assertEquals(parentConfig.isStrictMethodInvocation(), pkgConfig.isStrictMethodInvocation()); - } else if (!isSmiInheritanceEnabled && !parentConfig.isStrictMethodInvocation()){ + } else if (!isSmiInheritanceEnabled && !parentConfig.isStrictMethodInvocation()) { assertTrue(pkgConfig.isStrictMethodInvocation()); } } @@ -788,13 +906,13 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { } private PackageConfig makePackageConfig(String name, String namespace, PackageConfig parent, - String defaultResultType, ResultTypeConfig... results) { + String defaultResultType, ResultTypeConfig... results) { return makePackageConfig(name, namespace, parent, defaultResultType, results, null, null, null, true); } private PackageConfig makePackageConfig(String name, String namespace, PackageConfig parent, - String defaultResultType, ResultTypeConfig[] results, List<InterceptorConfig> interceptors, - List<InterceptorStackConfig> interceptorStacks, Set<String> globalAllowedMethods, boolean strictMethodInvocation) { + String defaultResultType, ResultTypeConfig[] results, List<InterceptorConfig> interceptors, + List<InterceptorStackConfig> interceptorStacks, Set<String> globalAllowedMethods, boolean strictMethodInvocation) { PackageConfig.Builder builder = new PackageConfig.Builder(name); if (namespace != null) { builder.namespace(namespace); @@ -852,7 +970,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { public boolean equals(Object obj) { PackageConfig other = (PackageConfig) obj; return getName().equals(other.getName()) && getNamespace().equals(other.getNamespace()) && - getParents().get(0) == other.getParents().get(0) && getParents().size() == other.getParents().size(); + getParents().get(0) == other.getParents().get(0) && getParents().size() == other.getParents().size(); } } @@ -893,7 +1011,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { T obj; if (type == ObjectFactory.class) { obj = type.getConstructor().newInstance(); - ((ObjectFactory)obj).setContainer(this); + ((ObjectFactory) obj).setContainer(this); OgnlReflectionProvider rp = new OgnlReflectionProvider() { @@ -929,7 +1047,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { } return obj; } catch (Exception e) { - throw new RuntimeException(e); + throw new RuntimeException(e); } } diff --git a/thoughts/shared/research/2025-12-02-WW-5593-convention-plugin-noclass-exception.md b/thoughts/shared/research/2025-12-02-WW-5593-convention-plugin-noclass-exception.md new file mode 100644 index 000000000..b4c7b4d14 --- /dev/null +++ b/thoughts/shared/research/2025-12-02-WW-5593-convention-plugin-noclass-exception.md @@ -0,0 +1,385 @@ +--- +date: 2025-12-02T18:33:08+01:00 +topic: "WW-5593: Convention plugin fails with NoClassDefFoundError" +tags: [research, codebase, convention-plugin, exception-handling, bug-analysis, WW-5593] +status: complete +jira_ticket: WW-5593 +related_tickets: [WW-5594] +--- + +# Research: WW-5593 - Convention Plugin NoClassDefFoundError Handling + +**Date**: 2025-12-02T18:33:08+01:00 +**JIRA**: [WW-5593](https://issues.apache.org/jira/browse/WW-5593) +**Related**: [WW-5594](https://issues.apache.org/jira/browse/WW-5594) + +## Research Question + +Why does the convention plugin fail completely with `NoClassDefFoundError` when scanning classes with missing dependencies, instead of gracefully logging and continuing? + +## Summary + +The `PackageBasedActionConfigBuilder` class (line 664) only catches `ClassNotFoundException` but not `NoClassDefFoundError` when testing action class candidates during classpath scanning. This causes complete application startup failures instead of graceful degradation when classes have missing optional dependencies. + +The issue is triggered when `struts.convention.action.includeJars` is configured, causing the plugin to scan struts2-core.jar which contains `XWorkTestCase` - a test utility class depending on JUnit (an optional dependency). + +## Detailed Findings + +### 1. The Exception Handling Bug + +**Location**: `plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java:662-667` + +**Current Code**: +```java +try { + return inPackage && (nameMatches || (checkImplementsAction && org.apache.struts2.action.Action.class.isAssignableFrom(classInfo.get()))); +} catch (ClassNotFoundException ex) { + LOG.error("Unable to load class [{}]", classInfo.getName(), ex); + return false; +} +``` + +**Problem**: Only catches `ClassNotFoundException`, missing `NoClassDefFoundError`. + +### 2. Exception Hierarchy + +``` +java.lang.Throwable + ├─ java.lang.Exception + │ └─ java.lang.ClassNotFoundException + └─ java.lang.Error + └─ java.lang.LinkageError + └─ java.lang.NoClassDefFoundError +``` + +**Key Point**: `NoClassDefFoundError` and `ClassNotFoundException` are siblings under `Throwable`, not parent-child. You cannot catch `NoClassDefFoundError` by catching `ClassNotFoundException`. + +**Difference**: +- **ClassNotFoundException**: Thrown when `Class.forName()` or `ClassLoader.loadClass()` cannot find the class definition +- **NoClassDefFoundError**: Thrown when a class was found at compile time but cannot be loaded at runtime due to: + - Missing dependencies (JAR files) + - Class initialization failures + - Static initializer blocks throw exceptions + - Incompatible class versions + - Classloader isolation issues + +### 3. Where NoClassDefFoundError Originates + +**Location**: `core/src/main/java/org/apache/struts2/util/finder/ClassFinder.java:224-235` + +```java +public Class<?> get() throws ClassNotFoundException { + if (clazz != null) return clazz; + if (notFound != null) throw notFound; + try { + this.clazz = classFinder.getClassLoaderInterface().loadClass(name); + return clazz; + } catch (ClassNotFoundException notFound) { + classFinder.getClassesNotLoaded().add(name); + this.notFound = notFound; + throw notFound; + } +} +``` + +**Analysis**: +- The method signature declares `throws ClassNotFoundException` +- The catch block only handles `ClassNotFoundException` +- However, `ClassLoader.loadClass()` can also throw `NoClassDefFoundError` when class dependencies are missing +- This `NoClassDefFoundError` propagates uncaught through `ClassInfo.get()` + +### 4. Call Stack During Failure + +From the email thread error log: + +``` +ERROR [org.apache.struts2.convention.DefaultClassFinder] (default task-1) +Error loading class [org.apache.struts2.XWorkTestCase]: +java.lang.NoClassDefFoundError: Failed to link org/apache/struts2/XWorkTestCase +(Module "deployment.coreweb.war" from Service Module Loader): junit/framework/TestCase + at java.base/java.lang.ClassLoader.defineClass1(Native Method) + at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1017) + ... + at deployment.coreweb.war//org.apache.struts2.util.finder.ClassFinder$ClassInfo.get(ClassFinder.java:228) + at deployment.coreweb.war//org.apache.struts2.convention.PackageBasedActionConfigBuilder$1.test(PackageBasedActionConfigBuilder.java:6XX) + at deployment.coreweb.war//org.apache.struts2.convention.DefaultClassFinder.findClasses(DefaultClassFinder.java:280) +``` + +The error occurs when: +1. Convention plugin scans for action classes +2. Finds `org.apache.struts2.XWorkTestCase` as a candidate +3. Calls `classInfo.get()` to load the class +4. ClassLoader attempts to link the class and load `junit.framework.TestCase` +5. JUnit is not available at runtime (optional dependency) +6. `NoClassDefFoundError` is thrown +7. **Not caught** by `PackageBasedActionConfigBuilder` catch block +8. Application startup fails completely + +### 5. Correct Exception Handling Patterns in Struts + +The Struts codebase already uses correct patterns in other locations: + +#### Pattern A: Catch Throwable (Most Defensive) + +**Location**: `plugins/convention/src/main/java/org/apache/struts2/convention/DefaultClassFinder.java:283-286` + +```java +} catch (Throwable e) { + LOG.error("Error loading class [{}]", classInfo.getName(), e); + classesNotLoaded.add(classInfo.getName()); +} +``` + +**Also used at**: Lines 248-251, 267-270, 297-300 + +**Advantage**: Catches all exceptions and errors, including: +- `ClassNotFoundException` +- `NoClassDefFoundError` +- `LinkageError` +- `ExceptionInInitializerError` +- Any other unexpected errors + +#### Pattern B: Multi-Catch with Specific Exceptions + +**Location**: `core/src/main/java/org/apache/struts2/config/providers/XmlDocConfigurationProvider.java:580-582` + +```java +} catch (ClassNotFoundException | NoClassDefFoundError e) { + throw new ConfigurationException("Result class [" + className + "] not found", e, loc); +} +``` + +**Location**: `core/src/main/java/org/apache/struts2/factory/DefaultInterceptorFactory.java:91-93` + +```java +} catch (NoClassDefFoundError e) { + cause = e; + message = "Could not load class " + interceptorClassName + ". Perhaps it exists but certain dependencies are not available?"; +} +``` + +**Advantage**: Explicit about what exceptions are expected, provides better error messages + +#### Pattern C: Insufficient (Current Bug) + +**Location**: `plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java:664` + +```java +} catch (ClassNotFoundException ex) { + LOG.error("Unable to load class [{}]", classInfo.getName(), ex); + return false; +} +``` + +**Problem**: Misses `NoClassDefFoundError` and other linkage errors + +### 6. When NoClassDefFoundError Occurs + +**Common Scenarios**: +1. **Application redeployment** (as mentioned in related research) +2. **Optional dependencies missing** (like JUnit for test classes) +3. **Classloader isolation** in application servers (JBoss, WebSphere, etc.) +4. **Static initializer failures** in class being loaded +5. **Split package issues** in modular systems +6. **Incompatible class versions** between dependencies + +### 7. Trigger Condition + +**From Email Thread**: Setting this constant triggers the bug: + +```xml +<constant name="struts.convention.action.includeJars" + value=".*?/myjar.*?jar(!/)?" /> +``` + +**Why**: +- By default, convention plugin only scans application's own classes +- Setting `struts.convention.action.includeJars` causes scanning of JAR files +- This includes struts2-core.jar itself +- struts2-core.jar contains `org.apache.struts2.XWorkTestCase` +- `XWorkTestCase` depends on `junit.framework.TestCase` +- JUnit is optional (scope: test) +- At runtime, JUnit not available → `NoClassDefFoundError` + +## Code References + +- `plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java:664` - Bug location +- `core/src/main/java/org/apache/struts2/util/finder/ClassFinder.java:228` - Where NoClassDefFoundError originates +- `plugins/convention/src/main/java/org/apache/struts2/convention/DefaultClassFinder.java:283-286` - Correct pattern (catches Throwable) +- `core/src/main/java/org/apache/struts2/factory/DefaultInterceptorFactory.java:91-93` - Precedent for NoClassDefFoundError handling +- `core/src/main/java/org/apache/struts2/config/providers/XmlDocConfigurationProvider.java:580-582` - Multi-catch pattern + +## Recommended Fix + +### Option A: Multi-Catch (Recommended) + +**File**: `plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java:664` + +```java +} catch (ClassNotFoundException | NoClassDefFoundError ex) { + LOG.error("Unable to load class [{}]. Perhaps it exists but certain dependencies are not available?", + classInfo.getName(), ex); + return false; +} +``` + +**Advantages**: +- Explicit about expected exceptions +- Follows pattern from `XmlDocConfigurationProvider` +- Provides helpful error message +- Java 7+ multi-catch syntax + +### Option B: Catch Throwable (More Defensive) + +```java +} catch (Throwable ex) { + LOG.error("Unable to load class [{}]", classInfo.getName(), ex); + return false; +} +``` + +**Advantages**: +- Matches pattern in `DefaultClassFinder` +- Handles any unexpected errors +- Most defensive approach + +**Disadvantages**: +- Less specific +- Could mask programming errors + +### Recommendation + +Use **Option A** (multi-catch) because: +1. It's explicit about the expected error conditions +2. Follows established pattern in `XmlDocConfigurationProvider` +3. Provides better error message for users +4. Still maintains good defensive programming + +### Additional Improvements + +Consider adding to the log message: +```java +LOG.error("Unable to load class [{}]. Perhaps it exists but certain dependencies are not available? " + + "If this is a test class or from a library, consider adding it to struts.convention.exclude.packages", + classInfo.getName(), ex); +``` + +## Impact Assessment + +### Current Risk + +**Severity**: HIGH + +**Scenarios Affected**: +1. Applications using `struts.convention.action.includeJars` +2. Applications with optional dependencies on scanned classes +3. Redeployment scenarios with classloader issues +4. Application server environments with module isolation (JBoss, WebSphere) + +**Current Behavior**: +- ❌ Complete application startup failure +- ❌ No graceful degradation +- ❌ Misleading error (appears critical, not missing optional dependency) +- ❌ Users must add workarounds to proceed + +### After Fix + +**Expected Behavior**: +- ✅ Graceful degradation - problematic classes logged and skipped +- ✅ Application continues to function +- ✅ Only unavailable actions are affected, not entire app +- ✅ Clear error messages indicating dependency issues +- ✅ Consistent with `DefaultClassFinder` behavior + +## Testing Strategy + +### Unit Tests to Add + +1. **Test NoClassDefFoundError Handling**: +```java +@Test +public void testHandlesNoClassDefFoundError() { + // Mock a ClassInfo that throws NoClassDefFoundError + ClassInfo classInfo = mock(ClassInfo.class); + when(classInfo.get()).thenThrow(new NoClassDefFoundError("junit/framework/TestCase")); + + // Should return false (exclude class) instead of propagating error + assertFalse(builder.includeClassNameInActionScan("org.apache.struts2.XWorkTestCase")); + + // Should log error + verify(mockLogger).error(contains("Unable to load class"), any(NoClassDefFoundError.class)); +} +``` + +2. **Test ClassNotFoundException Handling** (existing behavior): +```java +@Test +public void testHandlesClassNotFoundException() { + ClassInfo classInfo = mock(ClassInfo.class); + when(classInfo.get()).thenThrow(new ClassNotFoundException("com.example.MissingClass")); + + assertFalse(builder.includeClassNameInActionScan("com.example.MissingClass")); + verify(mockLogger).error(contains("Unable to load class"), any(ClassNotFoundException.class)); +} +``` + +3. **Test ExceptionInInitializerError** (edge case): +```java +@Test +public void testHandlesExceptionInInitializerError() { + ClassInfo classInfo = mock(ClassInfo.class); + when(classInfo.get()).thenThrow(new ExceptionInInitializerError("Static init failed")); + + assertFalse(builder.includeClassNameInActionScan("com.example.BadStaticInit")); +} +``` + +### Integration Tests + +1. Create a test JAR with a class that has missing dependencies +2. Configure `struts.convention.action.includeJars` to scan that JAR +3. Verify application starts successfully despite the problematic class +4. Verify error is logged appropriately + +## Workaround for Users + +Until the fix is released, users can: + +**Option 1**: Exclude problematic packages: +```xml +<constant name="struts.convention.exclude.packages" + value="org.apache.struts2,org.apache.struts2.*" /> +``` + +**Option 2**: Include JUnit at runtime (not recommended): +```xml +<dependency> + <groupId>junit</groupId> + <artifactId>junit</artifactId> + <version>4.13.2</version> + <scope>runtime</scope> +</dependency> +``` + +**Option 3**: Don't use `struts.convention.action.includeJars` if not needed + +## Related Issues + +- **WW-5594**: Wildcard pattern matching issue preventing proper exclusion of org.apache.struts2 package +- Related research: `thoughts/shared/research/2025-12-02-memory-leak-redeployment.md` (redeployment issues) + +## Email Thread Reference + +**From**: Florian Schlittgen ([email protected]) +**Date**: December 19, 2024 - January 19, 2025 +**Subject**: Struts 7: action class finder +**List**: [email protected] + +## Next Steps + +1. ✅ Create JIRA ticket WW-5593 +2. ⏳ Implement fix in `PackageBasedActionConfigBuilder.java:664` +3. ⏳ Add unit tests for NoClassDefFoundError handling +4. ⏳ Add integration tests with missing dependencies +5. ⏳ Update any other catch blocks with same issue +6. ⏳ Review release notes to document the fix \ No newline at end of file
