Lukasz Lenart created WW-5687:
---------------------------------

             Summary: Clear the conversion and validator caches on 
Dispatcher.cleanup() (WW-5537 defence-in-depth follow-on)
                 Key: WW-5687
                 URL: https://issues.apache.org/jira/browse/WW-5687
             Project: Struts 2
          Issue Type: Improvement
            Reporter: Lukasz Lenart


Follow-on from WW-5537 (Closed/Fixed, shipped 6.9.0 and 7.2.1), which built the 
{{InternalDestroyable}} cleanup framework. This item was identified during that 
work but never filed, and is still not on {{main}} as of 2026-08-23.

h3. What it is, and what it is not

{{StrutsTypeConverterHolder}} (fields {{mappings}}, {{defaultMappings}}, 
{{unknownMappingsInternal}}) and the {{ActionValidatorManager}} implementations 
(fields {{validatorCache}}, {{validatorFileCache}}) hold strong references to 
application {{Class}} objects and to converter/validator instances.

These are container-scoped singletons with no static, thread, or otherwise 
external references — they are reachable only through the container and are 
collected together with it. They therefore do _not_ independently pin the 
webapp classloader; that is determined by whatever retains the container, which 
WW-5537 already addressed.

_This is not a leak fix._ It is defence-in-depth, consistent with the other 
destroyables: clearing these caches during {{Dispatcher.cleanup()}} bounds 
retained heap in the window before the container graph is collected, and costs 
nothing if it already has been.

h3. The registration trap

The obvious implementation — make the two classes implement 
{{InternalDestroyable}} and add a second {{<bean type="...InternalDestroyable" 
.../>}} line for each — _does not work_, and this is the reason the item is 
worth a ticket rather than a five-line patch.

The Struts container keys factories by (type, name), so declaring the same 
class under two bean types produces two independent singletons. Verified 
empirically against {{main}}:

{code}FileManager(system)   = 1910936570 | 
InternalDestroyable(defaultFileManager)   = 807322507 | SAME? false
RootAccessor(struts)  = 1495608502 | InternalDestroyable(compoundRootAccessor) 
= 835773569 | SAME? false
{code}

{{Dispatcher.destroyInternalBeans()}} discovers destroyables with 
{{container.getInstanceNames(InternalDestroyable.class)}} and then 
{{container.getInstance(InternalDestroyable.class, name)}} — so it would 
receive a freshly built holder whose caches are empty, and clear nothing.

The existing dual-registered destroyables are unaffected by this, because what 
they clear is static: {{DefaultFileManager.clearCache()}} and 
{{CompoundRootAccessor.clearCache()}} are both {{static}} and clear {{static}} 
fields, so any instance will do. The caches in this ticket are per-instance, 
which is what breaks the pattern.

h3. Suggested implementation

Follow the adapter pattern already used for the other six destroyables 
({{ComponentCacheDestroyable}}, {{OgnlCacheDestroyable}}, 
{{ScopeInterceptorCacheDestroyable}}, {{FreemarkerCacheDestroyable}}, 
{{DebugUtilsCacheDestroyable}}, {{FinalizableReferenceQueueDestroyable}}): add 
small {{InternalDestroyable}} beans that resolve the _real_ singleton from the 
container and clear it, rather than making the holders themselves the 
destroyable.

Note that {{AnnotationActionValidatorManager extends 
DefaultActionValidatorManager}} and is the default binding ({{name="struts"}}); 
{{DefaultActionValidatorManager}} is bound separately as 
{{name="no-annotations"}}. Whatever is done must cover the instance actually in 
use, not just the base class.

h3. Verification

A test that asserts the caches are non-empty before {{Dispatcher.cleanup()}} 
and empty afterwards — and, importantly, that fails if the destroyable clears a 
different instance than the one the framework uses. A naive registration passes 
any test that only checks "destroy() was called".

h3. Provenance

The original plan text lived only in unpushed local commit {{8019e6994}} 
({{docs/superpowers/plans/2026-03-23-WW-5537-classloader-leak-fixes.md}}, Task 
5b), on a branch deleted 2026-08-23. Its Step 3 prescribed the naive dual 
registration described above, which the identity probe shows would be a no-op — 
that correction is the main thing this ticket adds over the original note.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to