[ 
https://issues.apache.org/jira/browse/WW-5687?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Lukasz Lenart updated WW-5687:
------------------------------
    Fix Version/s: 7.4.0

> 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
>            Priority: Minor
>             Fix For: 7.4.0
>
>
> 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